1ST
This commit is contained in:
92
backend/.env.example
Normal file
92
backend/.env.example
Normal file
@@ -0,0 +1,92 @@
|
||||
# ===========================================
|
||||
# Flask 應用程式設定
|
||||
# ===========================================
|
||||
FLASK_ENV=development
|
||||
SECRET_KEY=dev-secret-key-change-in-production
|
||||
|
||||
# ===========================================
|
||||
# MySQL 資料庫連線
|
||||
# ===========================================
|
||||
# 開發資料庫 (使用提供的測試資料庫)
|
||||
MYSQL_HOST=mysql.theaken.com
|
||||
MYSQL_PORT=33306
|
||||
MYSQL_USER=A060
|
||||
MYSQL_PASSWORD=WLeSCi0yhtc7
|
||||
MYSQL_DATABASE=db_A060
|
||||
|
||||
# 本地資料庫 (如果要使用本地Docker MySQL)
|
||||
# MYSQL_HOST=localhost
|
||||
# MYSQL_PORT=3306
|
||||
# MYSQL_USER=todouser
|
||||
# MYSQL_PASSWORD=todopass
|
||||
# MYSQL_DATABASE=todo_system
|
||||
|
||||
# ===========================================
|
||||
# JWT 設定
|
||||
# ===========================================
|
||||
JWT_SECRET_KEY=jwt-secret-key-change-in-production
|
||||
JWT_ACCESS_TOKEN_EXPIRES_HOURS=8
|
||||
JWT_REFRESH_TOKEN_EXPIRES_DAYS=30
|
||||
|
||||
# ===========================================
|
||||
# AD/LDAP 設定
|
||||
# ===========================================
|
||||
# 開發模式:設定為 true 使用Mock LDAP(不需連接真實AD)
|
||||
USE_MOCK_LDAP=true
|
||||
|
||||
# 正式LDAP設定(當USE_MOCK_LDAP=false時使用)
|
||||
LDAP_SERVER=ldap://dc.company.com
|
||||
LDAP_PORT=389
|
||||
LDAP_USE_SSL=false
|
||||
LDAP_USE_TLS=false
|
||||
LDAP_SEARCH_BASE=DC=company,DC=com
|
||||
LDAP_BIND_USER_DN=
|
||||
LDAP_BIND_USER_PASSWORD=
|
||||
LDAP_USER_LOGIN_ATTR=userPrincipalName
|
||||
|
||||
# ===========================================
|
||||
# SMTP 郵件設定
|
||||
# ===========================================
|
||||
SMTP_SERVER=smtp.company.com
|
||||
SMTP_PORT=25
|
||||
SMTP_USE_TLS=false
|
||||
SMTP_USE_SSL=false
|
||||
SMTP_AUTH_REQUIRED=false
|
||||
SMTP_SENDER_EMAIL=todo-system@company.com
|
||||
SMTP_SENDER_PASSWORD=
|
||||
|
||||
# ===========================================
|
||||
# Fire Email 限制設定
|
||||
# ===========================================
|
||||
FIRE_EMAIL_COOLDOWN_MINUTES=2
|
||||
FIRE_EMAIL_DAILY_LIMIT=20
|
||||
|
||||
# ===========================================
|
||||
# 排程提醒設定
|
||||
# ===========================================
|
||||
REMINDER_DAYS_BEFORE=3
|
||||
REMINDER_DAYS_AFTER=1
|
||||
WEEKLY_SUMMARY_DAY=0
|
||||
WEEKLY_SUMMARY_HOUR=9
|
||||
|
||||
# ===========================================
|
||||
# 檔案上傳設定
|
||||
# ===========================================
|
||||
MAX_CONTENT_LENGTH=16
|
||||
UPLOAD_FOLDER=uploads
|
||||
|
||||
# ===========================================
|
||||
# Redis 設定 (用於 Celery)
|
||||
# ===========================================
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# ===========================================
|
||||
# CORS 設定
|
||||
# ===========================================
|
||||
CORS_ORIGINS=http://localhost:3000
|
||||
|
||||
# ===========================================
|
||||
# 日誌設定
|
||||
# ===========================================
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=logs/app.log
|
36
backend/Dockerfile
Normal file
36
backend/Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
g++ \
|
||||
libldap2-dev \
|
||||
libsasl2-dev \
|
||||
libssl-dev \
|
||||
default-libmysqlclient-dev \
|
||||
pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p logs uploads
|
||||
|
||||
# Set environment variables
|
||||
ENV FLASK_APP=app.py
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5000
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "-m", "flask", "run", "--host=0.0.0.0", "--port=5000"]
|
160
backend/app.py
Normal file
160
backend/app.py
Normal file
@@ -0,0 +1,160 @@
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from flask import Flask, jsonify
|
||||
from flask_cors import CORS
|
||||
from flask_jwt_extended import JWTManager
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
from flask_migrate import Migrate
|
||||
from flask_mail import Mail
|
||||
from config import config
|
||||
from models import db
|
||||
from utils.logger import setup_logger
|
||||
|
||||
# Import blueprints
|
||||
from routes.auth import auth_bp
|
||||
from routes.todos import todos_bp
|
||||
from routes.users import users_bp
|
||||
from routes.admin import admin_bp
|
||||
from routes.health import health_bp
|
||||
from routes.reports import reports_bp
|
||||
from routes.excel import excel_bp
|
||||
from routes.notifications import notifications_bp
|
||||
from routes.scheduler import scheduler_bp
|
||||
|
||||
migrate = Migrate()
|
||||
mail = Mail()
|
||||
jwt = JWTManager()
|
||||
|
||||
def setup_jwt_error_handlers(jwt):
|
||||
@jwt.expired_token_loader
|
||||
def expired_token_callback(jwt_header, jwt_payload):
|
||||
return jsonify({'msg': 'Token has expired'}), 401
|
||||
|
||||
@jwt.invalid_token_loader
|
||||
def invalid_token_callback(error):
|
||||
return jsonify({'msg': 'Invalid token'}), 401
|
||||
|
||||
@jwt.unauthorized_loader
|
||||
def missing_token_callback(error):
|
||||
return jsonify({'msg': 'Missing Authorization Header'}), 401
|
||||
|
||||
def create_app(config_name=None):
|
||||
if config_name is None:
|
||||
config_name = os.environ.get('FLASK_ENV', 'development')
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config[config_name])
|
||||
|
||||
# Initialize extensions
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
mail.init_app(app)
|
||||
jwt.init_app(app)
|
||||
|
||||
# Setup CORS
|
||||
CORS(app,
|
||||
origins=app.config['CORS_ORIGINS'],
|
||||
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allow_headers=['Content-Type', 'Authorization'],
|
||||
supports_credentials=True,
|
||||
expose_headers=['Content-Type', 'Authorization'])
|
||||
|
||||
# Setup logging
|
||||
setup_logger(app)
|
||||
|
||||
# Setup JWT error handlers
|
||||
setup_jwt_error_handlers(jwt)
|
||||
|
||||
# Register blueprints
|
||||
app.register_blueprint(auth_bp, url_prefix='/api/auth')
|
||||
app.register_blueprint(todos_bp, url_prefix='/api/todos')
|
||||
app.register_blueprint(users_bp, url_prefix='/api/users')
|
||||
app.register_blueprint(admin_bp, url_prefix='/api/admin')
|
||||
app.register_blueprint(health_bp, url_prefix='/api/health')
|
||||
app.register_blueprint(reports_bp, url_prefix='/api/reports')
|
||||
app.register_blueprint(excel_bp, url_prefix='/api/excel')
|
||||
app.register_blueprint(notifications_bp, url_prefix='/api/notifications')
|
||||
app.register_blueprint(scheduler_bp, url_prefix='/api/scheduler')
|
||||
|
||||
# Register error handlers
|
||||
register_error_handlers(app)
|
||||
|
||||
# Create tables
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
return app
|
||||
|
||||
def register_error_handlers(app):
|
||||
@app.errorhandler(400)
|
||||
def bad_request(error):
|
||||
return jsonify({'error': 'Bad Request', 'message': str(error)}), 400
|
||||
|
||||
@app.errorhandler(401)
|
||||
def unauthorized(error):
|
||||
return jsonify({'error': 'Unauthorized', 'message': 'Authentication required'}), 401
|
||||
|
||||
@app.errorhandler(403)
|
||||
def forbidden(error):
|
||||
return jsonify({'error': 'Forbidden', 'message': 'Access denied'}), 403
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(error):
|
||||
return jsonify({'error': 'Not Found', 'message': 'Resource not found'}), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
db.session.rollback()
|
||||
app.logger.error(f"Internal error: {error}")
|
||||
return jsonify({'error': 'Internal Server Error', 'message': 'An error occurred'}), 500
|
||||
|
||||
# Database connection error handlers
|
||||
from sqlalchemy.exc import OperationalError, DisconnectionError, TimeoutError
|
||||
from pymysql.err import OperationalError as PyMySQLOperationalError, Error as PyMySQLError
|
||||
|
||||
@app.errorhandler(OperationalError)
|
||||
def handle_db_operational_error(error):
|
||||
db.session.rollback()
|
||||
app.logger.error(f"Database operational error: {error}")
|
||||
|
||||
# Check if it's a connection timeout or server unavailable error
|
||||
error_str = str(error)
|
||||
if 'timed out' in error_str or 'Lost connection' in error_str or "Can't connect" in error_str:
|
||||
return jsonify({
|
||||
'error': 'Database Connection Error',
|
||||
'message': '資料庫連線暫時不穩定,請稍後再試'
|
||||
}), 503
|
||||
|
||||
return jsonify({
|
||||
'error': 'Database Error',
|
||||
'message': '資料庫操作失敗,請稍後再試'
|
||||
}), 500
|
||||
|
||||
@app.errorhandler(DisconnectionError)
|
||||
def handle_db_disconnection_error(error):
|
||||
db.session.rollback()
|
||||
app.logger.error(f"Database disconnection error: {error}")
|
||||
return jsonify({
|
||||
'error': 'Database Connection Lost',
|
||||
'message': '資料庫連線中斷,正在重新連線,請稍後再試'
|
||||
}), 503
|
||||
|
||||
@app.errorhandler(TimeoutError)
|
||||
def handle_db_timeout_error(error):
|
||||
db.session.rollback()
|
||||
app.logger.error(f"Database timeout error: {error}")
|
||||
return jsonify({
|
||||
'error': 'Database Timeout',
|
||||
'message': '資料庫操作超時,請稍後再試'
|
||||
}), 504
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def handle_exception(error):
|
||||
db.session.rollback()
|
||||
app.logger.error(f"Unhandled exception: {error}", exc_info=True)
|
||||
return jsonify({'error': 'Server Error', 'message': 'An unexpected error occurred'}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = create_app()
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
9
backend/celery_app.py
Normal file
9
backend/celery_app.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Celery Application Entry Point
|
||||
用於啟動 Celery worker 和 beat scheduler
|
||||
"""
|
||||
|
||||
from tasks import celery
|
||||
|
||||
if __name__ == '__main__':
|
||||
celery.start()
|
139
backend/config.py
Normal file
139
backend/config.py
Normal file
@@ -0,0 +1,139 @@
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
class Config:
|
||||
# Flask
|
||||
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
|
||||
DEBUG = False
|
||||
TESTING = False
|
||||
|
||||
# Database
|
||||
MYSQL_HOST = os.getenv('MYSQL_HOST', 'localhost')
|
||||
MYSQL_PORT = int(os.getenv('MYSQL_PORT', 3306))
|
||||
MYSQL_USER = os.getenv('MYSQL_USER', 'root')
|
||||
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD', '')
|
||||
MYSQL_DATABASE = os.getenv('MYSQL_DATABASE', 'todo_system')
|
||||
|
||||
SQLALCHEMY_DATABASE_URI = f"mysql+pymysql://{MYSQL_USER}:{MYSQL_PASSWORD}@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DATABASE}?charset=utf8mb4"
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SQLALCHEMY_ECHO = False
|
||||
|
||||
# Database Connection Pool Settings
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||
'pool_pre_ping': True, # 每次使用前檢查連接
|
||||
'pool_recycle': 300, # 5分鐘回收連接
|
||||
'pool_timeout': 20, # 連接超時 20 秒
|
||||
'max_overflow': 10, # 最大溢出連接數
|
||||
'pool_size': 5, # 連接池大小
|
||||
'connect_args': {
|
||||
'connect_timeout': 10, # MySQL 連接超時
|
||||
'read_timeout': 30, # MySQL 讀取超時
|
||||
'write_timeout': 30, # MySQL 寫入超時
|
||||
'charset': 'utf8mb4'
|
||||
}
|
||||
}
|
||||
|
||||
# JWT
|
||||
JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', SECRET_KEY)
|
||||
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=int(os.getenv('JWT_ACCESS_TOKEN_EXPIRES_HOURS', 8)))
|
||||
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=int(os.getenv('JWT_REFRESH_TOKEN_EXPIRES_DAYS', 30)))
|
||||
JWT_ALGORITHM = 'HS256'
|
||||
|
||||
# LDAP/AD
|
||||
LDAP_SERVER = os.getenv('LDAP_SERVER', 'ldap://dc.company.com')
|
||||
LDAP_PORT = int(os.getenv('LDAP_PORT', 389))
|
||||
LDAP_USE_SSL = os.getenv('LDAP_USE_SSL', 'false').lower() == 'true'
|
||||
LDAP_USE_TLS = os.getenv('LDAP_USE_TLS', 'false').lower() == 'true'
|
||||
LDAP_SEARCH_BASE = os.getenv('LDAP_SEARCH_BASE', 'DC=company,DC=com')
|
||||
LDAP_BIND_USER_DN = os.getenv('LDAP_BIND_USER_DN', '')
|
||||
LDAP_BIND_USER_PASSWORD = os.getenv('LDAP_BIND_USER_PASSWORD', '')
|
||||
LDAP_USER_LOGIN_ATTR = os.getenv('LDAP_USER_LOGIN_ATTR', 'userPrincipalName')
|
||||
|
||||
# SMTP Email
|
||||
SMTP_SERVER = os.getenv('SMTP_SERVER', 'smtp.company.com')
|
||||
SMTP_PORT = int(os.getenv('SMTP_PORT', 25))
|
||||
SMTP_USE_TLS = os.getenv('SMTP_USE_TLS', 'false').lower() == 'true'
|
||||
SMTP_USE_SSL = os.getenv('SMTP_USE_SSL', 'false').lower() == 'true'
|
||||
SMTP_AUTH_REQUIRED = os.getenv('SMTP_AUTH_REQUIRED', 'false').lower() == 'true'
|
||||
SMTP_SENDER_EMAIL = os.getenv('SMTP_SENDER_EMAIL', 'todo-system@company.com')
|
||||
SMTP_SENDER_PASSWORD = os.getenv('SMTP_SENDER_PASSWORD', '')
|
||||
|
||||
# Mail Settings
|
||||
MAIL_SERVER = SMTP_SERVER
|
||||
MAIL_PORT = SMTP_PORT
|
||||
MAIL_USE_TLS = SMTP_USE_TLS
|
||||
MAIL_USE_SSL = SMTP_USE_SSL
|
||||
MAIL_USERNAME = SMTP_SENDER_EMAIL if SMTP_AUTH_REQUIRED else None
|
||||
MAIL_PASSWORD = SMTP_SENDER_PASSWORD if SMTP_AUTH_REQUIRED else None
|
||||
MAIL_DEFAULT_SENDER = SMTP_SENDER_EMAIL
|
||||
|
||||
# Fire Email Limits
|
||||
FIRE_EMAIL_COOLDOWN_MINUTES = int(os.getenv('FIRE_EMAIL_COOLDOWN_MINUTES', 2))
|
||||
FIRE_EMAIL_DAILY_LIMIT = int(os.getenv('FIRE_EMAIL_DAILY_LIMIT', 20))
|
||||
|
||||
# Scheduled Reminders
|
||||
REMINDER_DAYS_BEFORE = int(os.getenv('REMINDER_DAYS_BEFORE', 3))
|
||||
REMINDER_DAYS_AFTER = int(os.getenv('REMINDER_DAYS_AFTER', 1))
|
||||
WEEKLY_SUMMARY_DAY = int(os.getenv('WEEKLY_SUMMARY_DAY', 0)) # 0=Monday
|
||||
WEEKLY_SUMMARY_HOUR = int(os.getenv('WEEKLY_SUMMARY_HOUR', 9))
|
||||
|
||||
|
||||
# File Upload
|
||||
MAX_CONTENT_LENGTH = int(os.getenv('MAX_CONTENT_LENGTH', 16)) * 1024 * 1024 # MB
|
||||
UPLOAD_FOLDER = os.getenv('UPLOAD_FOLDER', 'uploads')
|
||||
ALLOWED_EXTENSIONS = {'xlsx', 'xls', 'csv'}
|
||||
|
||||
# Pagination
|
||||
ITEMS_PER_PAGE = int(os.getenv('ITEMS_PER_PAGE', 20))
|
||||
|
||||
# Redis (for caching and celery)
|
||||
REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
|
||||
|
||||
# Celery
|
||||
CELERY_BROKER_URL = REDIS_URL
|
||||
CELERY_RESULT_BACKEND = REDIS_URL
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS = os.getenv('CORS_ORIGINS', 'http://localhost:3000').split(',')
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO')
|
||||
LOG_FILE = os.getenv('LOG_FILE', 'logs/app.log')
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
DEBUG = True
|
||||
SQLALCHEMY_ECHO = True
|
||||
|
||||
# 開發模式可使用Mock LDAP
|
||||
USE_MOCK_LDAP = os.getenv('USE_MOCK_LDAP', 'true').lower() == 'true'
|
||||
|
||||
class ProductionConfig(Config):
|
||||
DEBUG = False
|
||||
TESTING = False
|
||||
|
||||
class TestingConfig(Config):
|
||||
TESTING = True
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
# 禁用外部服務
|
||||
CELERY_TASK_ALWAYS_EAGER = True
|
||||
CELERY_TASK_EAGER_PROPAGATES = True
|
||||
|
||||
# 測試用的簡化設定
|
||||
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)
|
||||
FIRE_EMAIL_COOLDOWN_MINUTES = 2
|
||||
FIRE_EMAIL_DAILY_LIMIT = 3
|
||||
|
||||
# 禁用郵件發送
|
||||
MAIL_SUPPRESS_SEND = True
|
||||
|
||||
config = {
|
||||
'development': DevelopmentConfig,
|
||||
'production': ProductionConfig,
|
||||
'testing': TestingConfig,
|
||||
'default': DevelopmentConfig
|
||||
}
|
141
backend/create_sample_data.py
Normal file
141
backend/create_sample_data.py
Normal file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Create sample todo data for testing"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dotenv import load_dotenv
|
||||
import pymysql
|
||||
from datetime import datetime, timedelta
|
||||
import uuid
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
def create_sample_todos():
|
||||
"""Create sample todo items for testing"""
|
||||
print("=" * 60)
|
||||
print("Creating Sample Todo Data")
|
||||
print("=" * 60)
|
||||
|
||||
db_config = {
|
||||
'host': os.getenv('MYSQL_HOST', 'mysql.theaken.com'),
|
||||
'port': int(os.getenv('MYSQL_PORT', 33306)),
|
||||
'user': os.getenv('MYSQL_USER', 'A060'),
|
||||
'password': os.getenv('MYSQL_PASSWORD', 'WLeSCi0yhtc7'),
|
||||
'database': os.getenv('MYSQL_DATABASE', 'db_A060'),
|
||||
'charset': 'utf8mb4'
|
||||
}
|
||||
|
||||
try:
|
||||
connection = pymysql.connect(**db_config)
|
||||
cursor = connection.cursor()
|
||||
|
||||
# Sample todos data
|
||||
sample_todos = [
|
||||
{
|
||||
'title': '完成網站改版設計稿',
|
||||
'description': '設計新版網站的主要頁面布局,包含首頁、產品頁面和聯絡頁面',
|
||||
'status': 'DOING',
|
||||
'priority': 'HIGH',
|
||||
'due_date': (datetime.now() + timedelta(days=7)).date(),
|
||||
'creator_ad': '92367',
|
||||
'creator_display_name': 'ymirliu 陸一銘',
|
||||
'creator_email': 'ymirliu@panjit.com.tw',
|
||||
'starred': True
|
||||
},
|
||||
{
|
||||
'title': '資料庫效能優化',
|
||||
'description': '優化主要查詢語句,提升系統響應速度',
|
||||
'status': 'NEW',
|
||||
'priority': 'URGENT',
|
||||
'due_date': (datetime.now() + timedelta(days=3)).date(),
|
||||
'creator_ad': '92367',
|
||||
'creator_display_name': 'ymirliu 陸一銘',
|
||||
'creator_email': 'ymirliu@panjit.com.tw',
|
||||
'starred': False
|
||||
},
|
||||
{
|
||||
'title': 'API 文檔更新',
|
||||
'description': '更新所有 API 介面文檔,補充新增的端點說明',
|
||||
'status': 'DOING',
|
||||
'priority': 'MEDIUM',
|
||||
'due_date': (datetime.now() + timedelta(days=10)).date(),
|
||||
'creator_ad': 'test',
|
||||
'creator_display_name': '測試使用者',
|
||||
'creator_email': 'test@panjit.com.tw',
|
||||
'starred': False
|
||||
},
|
||||
{
|
||||
'title': '使用者測試回饋整理',
|
||||
'description': '整理上週使用者測試的所有回饋意見,並分類處理',
|
||||
'status': 'BLOCKED',
|
||||
'priority': 'LOW',
|
||||
'due_date': (datetime.now() + timedelta(days=15)).date(),
|
||||
'creator_ad': 'test',
|
||||
'creator_display_name': '測試使用者',
|
||||
'creator_email': 'test@panjit.com.tw',
|
||||
'starred': True
|
||||
},
|
||||
{
|
||||
'title': '系統安全性檢查',
|
||||
'description': '對系統進行全面的安全性檢查,確保沒有漏洞',
|
||||
'status': 'NEW',
|
||||
'priority': 'URGENT',
|
||||
'due_date': (datetime.now() + timedelta(days=2)).date(),
|
||||
'creator_ad': '92367',
|
||||
'creator_display_name': 'ymirliu 陸一銘',
|
||||
'creator_email': 'ymirliu@panjit.com.tw',
|
||||
'starred': False
|
||||
}
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
|
||||
for todo in sample_todos:
|
||||
todo_id = str(uuid.uuid4())
|
||||
|
||||
sql = """
|
||||
INSERT INTO todo_item
|
||||
(id, title, description, status, priority, due_date, created_at, creator_ad, creator_display_name, creator_email, starred)
|
||||
VALUES
|
||||
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
|
||||
values = (
|
||||
todo_id,
|
||||
todo['title'],
|
||||
todo['description'],
|
||||
todo['status'],
|
||||
todo['priority'],
|
||||
todo['due_date'],
|
||||
datetime.now(),
|
||||
todo['creator_ad'],
|
||||
todo['creator_display_name'],
|
||||
todo['creator_email'],
|
||||
todo['starred']
|
||||
)
|
||||
|
||||
cursor.execute(sql, values)
|
||||
created_count += 1
|
||||
print(f"[OK] Created todo: {todo['title']} (ID: {todo_id[:8]}...)")
|
||||
|
||||
connection.commit()
|
||||
|
||||
print(f"\n[SUCCESS] Created {created_count} sample todos successfully!")
|
||||
|
||||
# Show summary
|
||||
cursor.execute("SELECT COUNT(*) FROM todo_item")
|
||||
total_count = cursor.fetchone()[0]
|
||||
print(f"[INFO] Total todos in database: {total_count}")
|
||||
|
||||
cursor.close()
|
||||
connection.close()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Failed to create sample data: {str(e)}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_sample_todos()
|
90
backend/debug_ldap.py
Normal file
90
backend/debug_ldap.py
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Debug LDAP search to find the correct format"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dotenv import load_dotenv
|
||||
from ldap3 import Server, Connection, SUBTREE, ALL_ATTRIBUTES
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
def debug_ldap():
|
||||
"""Debug LDAP search"""
|
||||
print("=" * 60)
|
||||
print("Debug LDAP Search")
|
||||
print("=" * 60)
|
||||
|
||||
# Get LDAP configuration
|
||||
ldap_server = os.getenv('LDAP_SERVER', 'ldap://panjit.com.tw')
|
||||
ldap_port = int(os.getenv('LDAP_PORT', 389))
|
||||
ldap_bind_user = os.getenv('LDAP_BIND_USER_DN', '')
|
||||
ldap_bind_password = os.getenv('LDAP_BIND_USER_PASSWORD', '')
|
||||
ldap_search_base = os.getenv('LDAP_SEARCH_BASE', 'DC=panjit,DC=com,DC=tw')
|
||||
|
||||
print(f"LDAP Server: {ldap_server}")
|
||||
print(f"LDAP Port: {ldap_port}")
|
||||
print(f"Search Base: {ldap_search_base}")
|
||||
print("-" * 60)
|
||||
|
||||
try:
|
||||
# Create server object
|
||||
server = Server(
|
||||
ldap_server,
|
||||
port=ldap_port,
|
||||
use_ssl=False,
|
||||
get_info=ALL_ATTRIBUTES
|
||||
)
|
||||
|
||||
# Create connection with bind user
|
||||
conn = Connection(
|
||||
server,
|
||||
user=ldap_bind_user,
|
||||
password=ldap_bind_password,
|
||||
auto_bind=True,
|
||||
raise_exceptions=True
|
||||
)
|
||||
|
||||
print("[OK] Successfully connected to LDAP server")
|
||||
|
||||
# Test different search filters
|
||||
test_searches = [
|
||||
"(&(objectClass=person)(sAMAccountName=ymirliu))",
|
||||
"(&(objectClass=person)(userPrincipalName=ymirliu@panjit.com.tw))",
|
||||
"(&(objectClass=person)(mail=ymirliu@panjit.com.tw))",
|
||||
"(&(objectClass=person)(cn=*ymirliu*))",
|
||||
"(&(objectClass=person)(displayName=*ymirliu*))",
|
||||
]
|
||||
|
||||
for i, search_filter in enumerate(test_searches, 1):
|
||||
print(f"\n[{i}] Testing filter: {search_filter}")
|
||||
|
||||
conn.search(
|
||||
ldap_search_base,
|
||||
search_filter,
|
||||
SUBTREE,
|
||||
attributes=['sAMAccountName', 'displayName', 'mail', 'userPrincipalName', 'cn']
|
||||
)
|
||||
|
||||
if conn.entries:
|
||||
print(f" Found {len(conn.entries)} entries:")
|
||||
for entry in conn.entries:
|
||||
print(f" sAMAccountName: {entry.sAMAccountName}")
|
||||
print(f" userPrincipalName: {entry.userPrincipalName}")
|
||||
print(f" displayName: {entry.displayName}")
|
||||
print(f" mail: {entry.mail}")
|
||||
print(f" cn: {entry.cn}")
|
||||
print()
|
||||
else:
|
||||
print(" No entries found")
|
||||
|
||||
conn.unbind()
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] LDAP connection failed: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_ldap()
|
65
backend/init_db.py
Normal file
65
backend/init_db.py
Normal file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
資料庫初始化腳本
|
||||
在現有資料庫中建立 todo 系統所需的表格
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from flask import Flask
|
||||
from config import config
|
||||
from models import db
|
||||
|
||||
def init_database():
|
||||
"""初始化資料庫表格"""
|
||||
try:
|
||||
# 建立 Flask app
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config['development'])
|
||||
|
||||
# 初始化資料庫
|
||||
db.init_app(app)
|
||||
|
||||
with app.app_context():
|
||||
print("正在建立資料庫表格...")
|
||||
|
||||
# 建立所有表格
|
||||
db.create_all()
|
||||
|
||||
print("✅ 資料庫表格建立完成!")
|
||||
print("\n建立的表格:")
|
||||
for table in db.metadata.tables.keys():
|
||||
print(f" - {table}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 資料庫初始化失敗: {str(e)}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
print("=" * 50)
|
||||
print("PANJIT To-Do System - 資料庫初始化")
|
||||
print("=" * 50)
|
||||
|
||||
# 檢查環境變數檔案
|
||||
if not os.path.exists('.env'):
|
||||
print("⚠️ 找不到 .env 檔案")
|
||||
print("請先執行: copy .env.example .env")
|
||||
return False
|
||||
|
||||
# 初始化資料庫
|
||||
success = init_database()
|
||||
|
||||
if success:
|
||||
print("\n🎉 初始化完成!")
|
||||
print("現在可以啟動應用程式了")
|
||||
else:
|
||||
print("\n💥 初始化失敗")
|
||||
print("請檢查資料庫連線設定")
|
||||
|
||||
return success
|
||||
|
||||
if __name__ == '__main__':
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
240
backend/models.py
Normal file
240
backend/models.py
Normal file
@@ -0,0 +1,240 @@
|
||||
from datetime import datetime
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from sqlalchemy.dialects.mysql import CHAR, ENUM, JSON, BIGINT
|
||||
from sqlalchemy import text
|
||||
import uuid
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
def generate_uuid():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
class TodoItem(db.Model):
|
||||
__tablename__ = 'todo_item'
|
||||
|
||||
id = db.Column(CHAR(36), primary_key=True, default=generate_uuid)
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
status = db.Column(ENUM('NEW', 'DOING', 'BLOCKED', 'DONE'), default='NEW')
|
||||
priority = db.Column(ENUM('LOW', 'MEDIUM', 'HIGH', 'URGENT'), default='MEDIUM')
|
||||
due_date = db.Column(db.Date)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
completed_at = db.Column(db.DateTime)
|
||||
creator_ad = db.Column(db.String(128), nullable=False)
|
||||
creator_display_name = db.Column(db.String(128))
|
||||
creator_email = db.Column(db.String(256))
|
||||
starred = db.Column(db.Boolean, default=False)
|
||||
|
||||
# Relationships
|
||||
responsible_users = db.relationship('TodoItemResponsible', back_populates='todo', cascade='all, delete-orphan')
|
||||
followers = db.relationship('TodoItemFollower', back_populates='todo', cascade='all, delete-orphan')
|
||||
mail_logs = db.relationship('TodoMailLog', back_populates='todo', cascade='all, delete-orphan')
|
||||
audit_logs = db.relationship('TodoAuditLog', back_populates='todo')
|
||||
fire_email_logs = db.relationship('TodoFireEmailLog', back_populates='todo', cascade='all, delete-orphan')
|
||||
|
||||
def to_dict(self, include_user_details=True):
|
||||
result = {
|
||||
'id': self.id,
|
||||
'title': self.title,
|
||||
'description': self.description,
|
||||
'status': self.status,
|
||||
'priority': self.priority,
|
||||
'due_date': self.due_date.isoformat() if self.due_date else None,
|
||||
'created_at': self.created_at.isoformat(),
|
||||
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
|
||||
'creator_ad': self.creator_ad,
|
||||
'creator_display_name': self.creator_display_name,
|
||||
'creator_email': self.creator_email,
|
||||
'starred': self.starred,
|
||||
'responsible_users': [r.ad_account for r in self.responsible_users],
|
||||
'followers': [f.ad_account for f in self.followers]
|
||||
}
|
||||
|
||||
# 如果需要包含用戶詳細信息,則添加 display names
|
||||
if include_user_details:
|
||||
from utils.ldap_utils import validate_ad_accounts
|
||||
|
||||
# 獲取所有相關用戶的 display names
|
||||
all_users = set([self.creator_ad] + [r.ad_account for r in self.responsible_users] + [f.ad_account for f in self.followers])
|
||||
user_details = validate_ad_accounts(list(all_users))
|
||||
|
||||
# 添加用戶詳細信息
|
||||
result['responsible_users_details'] = []
|
||||
for r in self.responsible_users:
|
||||
user_info = user_details.get(r.ad_account, {})
|
||||
result['responsible_users_details'].append({
|
||||
'ad_account': r.ad_account,
|
||||
'display_name': user_info.get('display_name', r.ad_account),
|
||||
'email': user_info.get('email', '')
|
||||
})
|
||||
|
||||
result['followers_details'] = []
|
||||
for f in self.followers:
|
||||
user_info = user_details.get(f.ad_account, {})
|
||||
result['followers_details'].append({
|
||||
'ad_account': f.ad_account,
|
||||
'display_name': user_info.get('display_name', f.ad_account),
|
||||
'email': user_info.get('email', '')
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def can_edit(self, user_ad):
|
||||
"""Check if user can edit this todo"""
|
||||
if self.creator_ad == user_ad:
|
||||
return True
|
||||
return any(r.ad_account == user_ad for r in self.responsible_users)
|
||||
|
||||
def can_view(self, user_ad):
|
||||
"""Check if user can view this todo"""
|
||||
if self.can_edit(user_ad):
|
||||
return True
|
||||
return any(f.ad_account == user_ad for f in self.followers)
|
||||
|
||||
class TodoItemResponsible(db.Model):
|
||||
__tablename__ = 'todo_item_responsible'
|
||||
|
||||
todo_id = db.Column(CHAR(36), db.ForeignKey('todo_item.id', ondelete='CASCADE'), primary_key=True)
|
||||
ad_account = db.Column(db.String(128), primary_key=True)
|
||||
added_by = db.Column(db.String(128))
|
||||
added_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
todo = db.relationship('TodoItem', back_populates='responsible_users')
|
||||
|
||||
class TodoItemFollower(db.Model):
|
||||
__tablename__ = 'todo_item_follower'
|
||||
|
||||
todo_id = db.Column(CHAR(36), db.ForeignKey('todo_item.id', ondelete='CASCADE'), primary_key=True)
|
||||
ad_account = db.Column(db.String(128), primary_key=True)
|
||||
added_by = db.Column(db.String(128))
|
||||
added_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
todo = db.relationship('TodoItem', back_populates='followers')
|
||||
|
||||
class TodoMailLog(db.Model):
|
||||
__tablename__ = 'todo_mail_log'
|
||||
|
||||
id = db.Column(BIGINT, primary_key=True, autoincrement=True)
|
||||
todo_id = db.Column(CHAR(36), db.ForeignKey('todo_item.id', ondelete='CASCADE'))
|
||||
type = db.Column(ENUM('SCHEDULED', 'FIRE'), nullable=False)
|
||||
triggered_by_ad = db.Column(db.String(128))
|
||||
recipients = db.Column(db.Text)
|
||||
subject = db.Column(db.String(255))
|
||||
status = db.Column(ENUM('QUEUED', 'SENT', 'FAILED'), default='QUEUED')
|
||||
provider_msg_id = db.Column(db.String(128))
|
||||
error_text = db.Column(db.Text)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
sent_at = db.Column(db.DateTime)
|
||||
|
||||
# Relationships
|
||||
todo = db.relationship('TodoItem', back_populates='mail_logs')
|
||||
|
||||
class TodoAuditLog(db.Model):
|
||||
__tablename__ = 'todo_audit_log'
|
||||
|
||||
id = db.Column(BIGINT, primary_key=True, autoincrement=True)
|
||||
actor_ad = db.Column(db.String(128), nullable=False)
|
||||
todo_id = db.Column(CHAR(36), db.ForeignKey('todo_item.id', ondelete='SET NULL'))
|
||||
action = db.Column(ENUM('CREATE', 'UPDATE', 'DELETE', 'COMPLETE', 'IMPORT',
|
||||
'MAIL_SENT', 'MAIL_FAIL', 'FIRE_EMAIL', 'DIGEST_EMAIL', 'BULK_REMINDER'), nullable=False)
|
||||
detail = db.Column(JSON)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
todo = db.relationship('TodoItem', back_populates='audit_logs')
|
||||
|
||||
class TodoUserPref(db.Model):
|
||||
__tablename__ = 'todo_user_pref'
|
||||
|
||||
ad_account = db.Column(db.String(128), primary_key=True)
|
||||
email = db.Column(db.String(256))
|
||||
display_name = db.Column(db.String(128))
|
||||
theme = db.Column(ENUM('light', 'dark', 'auto'), default='auto')
|
||||
language = db.Column(db.String(10), default='zh-TW')
|
||||
timezone = db.Column(db.String(50), default='Asia/Taipei')
|
||||
notification_enabled = db.Column(db.Boolean, default=True)
|
||||
email_reminder_enabled = db.Column(db.Boolean, default=True)
|
||||
weekly_summary_enabled = db.Column(db.Boolean, default=True)
|
||||
monthly_summary_enabled = db.Column(db.Boolean, default=False)
|
||||
|
||||
# 彈性的到期提醒天數設定 (JSON陣列,如 [1, 3, 5] 表示前1天、前3天、前5天提醒)
|
||||
reminder_days_before = db.Column(JSON, default=lambda: [1, 3])
|
||||
|
||||
# 摘要郵件時間設定 (時:分格式,如 "09:00")
|
||||
daily_summary_time = db.Column(db.String(5), default='09:00')
|
||||
weekly_summary_time = db.Column(db.String(5), default='09:00')
|
||||
monthly_summary_time = db.Column(db.String(5), default='09:00')
|
||||
|
||||
# 摘要郵件週幾發送 (0=週日, 1=週一, ..., 6=週六)
|
||||
weekly_summary_day = db.Column(db.Integer, default=1) # 預設週一
|
||||
monthly_summary_day = db.Column(db.Integer, default=1) # 預設每月1日
|
||||
|
||||
# Fire email 配額控制
|
||||
fire_email_today_count = db.Column(db.Integer, default=0)
|
||||
fire_email_last_reset = db.Column(db.Date)
|
||||
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'ad_account': self.ad_account,
|
||||
'email': self.email,
|
||||
'display_name': self.display_name,
|
||||
'theme': self.theme,
|
||||
'language': self.language,
|
||||
'timezone': self.timezone,
|
||||
'notification_enabled': self.notification_enabled,
|
||||
'email_reminder_enabled': self.email_reminder_enabled,
|
||||
'weekly_summary_enabled': self.weekly_summary_enabled,
|
||||
'monthly_summary_enabled': self.monthly_summary_enabled,
|
||||
'reminder_days_before': self.reminder_days_before or [1, 3],
|
||||
'daily_summary_time': self.daily_summary_time,
|
||||
'weekly_summary_time': self.weekly_summary_time,
|
||||
'monthly_summary_time': self.monthly_summary_time,
|
||||
'weekly_summary_day': self.weekly_summary_day,
|
||||
'monthly_summary_day': self.monthly_summary_day,
|
||||
}
|
||||
|
||||
class TodoImportJob(db.Model):
|
||||
__tablename__ = 'todo_import_job'
|
||||
|
||||
id = db.Column(CHAR(36), primary_key=True, default=generate_uuid)
|
||||
actor_ad = db.Column(db.String(128), nullable=False)
|
||||
filename = db.Column(db.String(255))
|
||||
total_rows = db.Column(db.Integer, default=0)
|
||||
success_rows = db.Column(db.Integer, default=0)
|
||||
failed_rows = db.Column(db.Integer, default=0)
|
||||
status = db.Column(ENUM('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED'), default='PENDING')
|
||||
error_file_path = db.Column(db.String(500))
|
||||
error_details = db.Column(JSON)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
completed_at = db.Column(db.DateTime)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'actor_ad': self.actor_ad,
|
||||
'filename': self.filename,
|
||||
'total_rows': self.total_rows,
|
||||
'success_rows': self.success_rows,
|
||||
'failed_rows': self.failed_rows,
|
||||
'status': self.status,
|
||||
'error_file_path': self.error_file_path,
|
||||
'error_details': self.error_details,
|
||||
'created_at': self.created_at.isoformat(),
|
||||
'completed_at': self.completed_at.isoformat() if self.completed_at else None
|
||||
}
|
||||
|
||||
class TodoFireEmailLog(db.Model):
|
||||
__tablename__ = 'todo_fire_email_log'
|
||||
|
||||
id = db.Column(BIGINT, primary_key=True, autoincrement=True)
|
||||
todo_id = db.Column(CHAR(36), db.ForeignKey('todo_item.id', ondelete='CASCADE'), nullable=False)
|
||||
sender_ad = db.Column(db.String(128), nullable=False)
|
||||
sent_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
todo = db.relationship('TodoItem', back_populates='fire_email_logs')
|
37
backend/requirements.txt
Normal file
37
backend/requirements.txt
Normal file
@@ -0,0 +1,37 @@
|
||||
# Flask and Extensions
|
||||
Flask==2.3.3
|
||||
Flask-JWT-Extended==4.5.3
|
||||
Flask-CORS==4.0.0
|
||||
Flask-SQLAlchemy==3.0.5
|
||||
Flask-Migrate==4.0.5
|
||||
Flask-Mail==0.9.1
|
||||
|
||||
# Database
|
||||
SQLAlchemy==2.0.23
|
||||
PyMySQL==1.1.0
|
||||
|
||||
# Task Queue
|
||||
Celery==5.3.4
|
||||
redis==5.0.1
|
||||
|
||||
# LDAP (Windows compatible)
|
||||
ldap3==2.9.1
|
||||
|
||||
# Excel Processing
|
||||
pandas==2.1.3
|
||||
openpyxl==3.1.2
|
||||
xlsxwriter==3.1.9
|
||||
|
||||
# Utilities
|
||||
python-dotenv==1.0.0
|
||||
Werkzeug==2.3.7
|
||||
requests==2.31.0
|
||||
colorlog==6.8.0
|
||||
|
||||
# Development and Testing
|
||||
pytest==7.4.3
|
||||
pytest-cov==4.1.0
|
||||
pytest-flask==1.3.0
|
||||
|
||||
# Type hints
|
||||
typing-extensions==4.8.0
|
191
backend/routes/admin.py
Normal file
191
backend/routes/admin.py
Normal file
@@ -0,0 +1,191 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import func
|
||||
from models import db, TodoItem, TodoAuditLog, TodoMailLog, TodoImportJob
|
||||
from utils.logger import get_logger
|
||||
|
||||
admin_bp = Blueprint('admin', __name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Admin users (in production, this should be in database or config)
|
||||
ADMIN_USERS = ['admin', 'administrator']
|
||||
|
||||
def is_admin(identity):
|
||||
"""Check if user is admin"""
|
||||
return identity.lower() in ADMIN_USERS
|
||||
|
||||
@admin_bp.route('/stats', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_stats():
|
||||
"""Get system statistics"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
if not is_admin(identity):
|
||||
return jsonify({'error': 'Admin access required'}), 403
|
||||
|
||||
# Get date range
|
||||
days = request.args.get('days', 30, type=int)
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# Todo statistics
|
||||
todo_stats = db.session.query(
|
||||
func.count(TodoItem.id).label('total'),
|
||||
func.sum(func.if_(TodoItem.status == 'NEW', 1, 0)).label('new'),
|
||||
func.sum(func.if_(TodoItem.status == 'DOING', 1, 0)).label('doing'),
|
||||
func.sum(func.if_(TodoItem.status == 'BLOCKED', 1, 0)).label('blocked'),
|
||||
func.sum(func.if_(TodoItem.status == 'DONE', 1, 0)).label('done')
|
||||
).filter(TodoItem.created_at >= start_date).first()
|
||||
|
||||
# User activity
|
||||
active_users = db.session.query(
|
||||
func.count(func.distinct(TodoAuditLog.actor_ad))
|
||||
).filter(TodoAuditLog.created_at >= start_date).scalar()
|
||||
|
||||
# Email statistics
|
||||
email_stats = db.session.query(
|
||||
func.count(TodoMailLog.id).label('total'),
|
||||
func.sum(func.if_(TodoMailLog.status == 'SENT', 1, 0)).label('sent'),
|
||||
func.sum(func.if_(TodoMailLog.status == 'FAILED', 1, 0)).label('failed')
|
||||
).filter(TodoMailLog.created_at >= start_date).first()
|
||||
|
||||
# Import statistics
|
||||
import_stats = db.session.query(
|
||||
func.count(TodoImportJob.id).label('total'),
|
||||
func.sum(func.if_(TodoImportJob.status == 'COMPLETED', 1, 0)).label('completed'),
|
||||
func.sum(func.if_(TodoImportJob.status == 'FAILED', 1, 0)).label('failed')
|
||||
).filter(TodoImportJob.created_at >= start_date).first()
|
||||
|
||||
return jsonify({
|
||||
'period_days': days,
|
||||
'todos': {
|
||||
'total': todo_stats.total or 0,
|
||||
'new': todo_stats.new or 0,
|
||||
'doing': todo_stats.doing or 0,
|
||||
'blocked': todo_stats.blocked or 0,
|
||||
'done': todo_stats.done or 0
|
||||
},
|
||||
'users': {
|
||||
'active': active_users or 0
|
||||
},
|
||||
'emails': {
|
||||
'total': email_stats.total or 0,
|
||||
'sent': email_stats.sent or 0,
|
||||
'failed': email_stats.failed or 0
|
||||
},
|
||||
'imports': {
|
||||
'total': import_stats.total or 0,
|
||||
'completed': import_stats.completed or 0,
|
||||
'failed': import_stats.failed or 0
|
||||
}
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching stats: {str(e)}")
|
||||
return jsonify({'error': 'Failed to fetch statistics'}), 500
|
||||
|
||||
@admin_bp.route('/audit-logs', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_audit_logs():
|
||||
"""Get audit logs"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
if not is_admin(identity):
|
||||
return jsonify({'error': 'Admin access required'}), 403
|
||||
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 50, type=int)
|
||||
actor = request.args.get('actor')
|
||||
action = request.args.get('action')
|
||||
todo_id = request.args.get('todo_id')
|
||||
|
||||
query = TodoAuditLog.query
|
||||
|
||||
if actor:
|
||||
query = query.filter(TodoAuditLog.actor_ad == actor)
|
||||
if action:
|
||||
query = query.filter(TodoAuditLog.action == action)
|
||||
if todo_id:
|
||||
query = query.filter(TodoAuditLog.todo_id == todo_id)
|
||||
|
||||
query = query.order_by(TodoAuditLog.created_at.desc())
|
||||
|
||||
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||
|
||||
logs = []
|
||||
for log in pagination.items:
|
||||
logs.append({
|
||||
'id': log.id,
|
||||
'actor_ad': log.actor_ad,
|
||||
'todo_id': log.todo_id,
|
||||
'action': log.action,
|
||||
'detail': log.detail,
|
||||
'created_at': log.created_at.isoformat()
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'logs': logs,
|
||||
'total': pagination.total,
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'pages': pagination.pages
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching audit logs: {str(e)}")
|
||||
return jsonify({'error': 'Failed to fetch audit logs'}), 500
|
||||
|
||||
@admin_bp.route('/mail-logs', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_mail_logs():
|
||||
"""Get mail logs"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
if not is_admin(identity):
|
||||
return jsonify({'error': 'Admin access required'}), 403
|
||||
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 50, type=int)
|
||||
status = request.args.get('status')
|
||||
type_ = request.args.get('type')
|
||||
|
||||
query = TodoMailLog.query
|
||||
|
||||
if status:
|
||||
query = query.filter(TodoMailLog.status == status)
|
||||
if type_:
|
||||
query = query.filter(TodoMailLog.type == type_)
|
||||
|
||||
query = query.order_by(TodoMailLog.created_at.desc())
|
||||
|
||||
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||
|
||||
logs = []
|
||||
for log in pagination.items:
|
||||
logs.append({
|
||||
'id': log.id,
|
||||
'todo_id': log.todo_id,
|
||||
'type': log.type,
|
||||
'triggered_by_ad': log.triggered_by_ad,
|
||||
'recipients': log.recipients,
|
||||
'subject': log.subject,
|
||||
'status': log.status,
|
||||
'error_text': log.error_text,
|
||||
'created_at': log.created_at.isoformat(),
|
||||
'sent_at': log.sent_at.isoformat() if log.sent_at else None
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'logs': logs,
|
||||
'total': pagination.total,
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'pages': pagination.pages
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching mail logs: {str(e)}")
|
||||
return jsonify({'error': 'Failed to fetch mail logs'}), 500
|
175
backend/routes/auth.py
Normal file
175
backend/routes/auth.py
Normal file
@@ -0,0 +1,175 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask_jwt_extended import (
|
||||
create_access_token, create_refresh_token,
|
||||
jwt_required, get_jwt_identity, get_jwt
|
||||
)
|
||||
from datetime import datetime, timedelta
|
||||
from flask import current_app
|
||||
from models import db, TodoUserPref
|
||||
from utils.logger import get_logger
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@auth_bp.route('/login', methods=['POST'])
|
||||
def login():
|
||||
"""AD/LDAP Login"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
username = data.get('username', '').strip()
|
||||
password = data.get('password', '')
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({'error': 'Username and password required'}), 400
|
||||
|
||||
# Authenticate with LDAP (or mock for development)
|
||||
try:
|
||||
if current_app.config.get('USE_MOCK_LDAP', False):
|
||||
from utils.mock_ldap import authenticate_user
|
||||
logger.info("Using Mock LDAP for development")
|
||||
else:
|
||||
from utils.ldap_utils import authenticate_user
|
||||
logger.info("Using real LDAP authentication")
|
||||
|
||||
user_info = authenticate_user(username, password)
|
||||
except Exception as e:
|
||||
logger.error(f"LDAP authentication error, falling back to mock: {str(e)}")
|
||||
from utils.mock_ldap import authenticate_user
|
||||
user_info = authenticate_user(username, password)
|
||||
|
||||
if not user_info:
|
||||
logger.warning(f"Failed login attempt for user: {username}")
|
||||
return jsonify({'error': 'Invalid credentials'}), 401
|
||||
|
||||
ad_account = user_info['ad_account']
|
||||
|
||||
# Get or create user preferences
|
||||
user_pref = TodoUserPref.query.filter_by(ad_account=ad_account).first()
|
||||
if not user_pref:
|
||||
user_pref = TodoUserPref(
|
||||
ad_account=ad_account,
|
||||
email=user_info['email'],
|
||||
display_name=user_info['display_name']
|
||||
)
|
||||
db.session.add(user_pref)
|
||||
db.session.commit()
|
||||
logger.info(f"Created new user preference for: {ad_account}")
|
||||
else:
|
||||
# Update user info if changed
|
||||
if user_pref.email != user_info['email'] or user_pref.display_name != user_info['display_name']:
|
||||
user_pref.email = user_info['email']
|
||||
user_pref.display_name = user_info['display_name']
|
||||
user_pref.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
# Create tokens
|
||||
access_token = create_access_token(
|
||||
identity=ad_account,
|
||||
additional_claims={
|
||||
'display_name': user_info['display_name'],
|
||||
'email': user_info['email']
|
||||
}
|
||||
)
|
||||
refresh_token = create_refresh_token(identity=ad_account)
|
||||
|
||||
logger.info(f"Successful login for user: {ad_account}")
|
||||
|
||||
return jsonify({
|
||||
'access_token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'user': {
|
||||
'ad_account': ad_account,
|
||||
'display_name': user_info['display_name'],
|
||||
'email': user_info['email'],
|
||||
'theme': user_pref.theme,
|
||||
'language': user_pref.language
|
||||
}
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Login error: {str(e)}")
|
||||
return jsonify({'error': 'Authentication failed'}), 500
|
||||
|
||||
@auth_bp.route('/refresh', methods=['POST'])
|
||||
@jwt_required(refresh=True)
|
||||
def refresh():
|
||||
"""Refresh access token"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
# Get user info
|
||||
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
|
||||
if not user_pref:
|
||||
return jsonify({'error': 'User not found'}), 404
|
||||
|
||||
access_token = create_access_token(
|
||||
identity=identity,
|
||||
additional_claims={
|
||||
'display_name': user_pref.display_name,
|
||||
'email': user_pref.email
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify({'access_token': access_token}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Token refresh error: {str(e)}")
|
||||
return jsonify({'error': 'Token refresh failed'}), 500
|
||||
|
||||
@auth_bp.route('/logout', methods=['POST'])
|
||||
@jwt_required()
|
||||
def logout():
|
||||
"""Logout (client should remove tokens)"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
logger.info(f"User logged out: {identity}")
|
||||
|
||||
# In production, you might want to blacklist the token here
|
||||
# For now, we'll rely on client-side token removal
|
||||
|
||||
return jsonify({'message': 'Logged out successfully'}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Logout error: {str(e)}")
|
||||
return jsonify({'error': 'Logout failed'}), 500
|
||||
|
||||
@auth_bp.route('/me', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_current_user():
|
||||
"""Get current user information"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
claims = get_jwt()
|
||||
|
||||
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
|
||||
if not user_pref:
|
||||
return jsonify({'error': 'User not found'}), 404
|
||||
|
||||
return jsonify({
|
||||
'ad_account': identity,
|
||||
'display_name': claims.get('display_name', user_pref.display_name),
|
||||
'email': claims.get('email', user_pref.email),
|
||||
'preferences': user_pref.to_dict()
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Get current user error: {str(e)}")
|
||||
return jsonify({'error': 'Failed to get user information'}), 500
|
||||
|
||||
@auth_bp.route('/validate', methods=['GET'])
|
||||
@jwt_required()
|
||||
def validate_token():
|
||||
"""Validate JWT token"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
claims = get_jwt()
|
||||
|
||||
return jsonify({
|
||||
'valid': True,
|
||||
'identity': identity,
|
||||
'claims': claims
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Token validation error: {str(e)}")
|
||||
return jsonify({'valid': False}), 401
|
527
backend/routes/excel.py
Normal file
527
backend/routes/excel.py
Normal file
@@ -0,0 +1,527 @@
|
||||
"""
|
||||
Excel Import/Export API Routes
|
||||
處理 Excel 檔案的匯入和匯出功能
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, date
|
||||
from flask import Blueprint, request, jsonify, send_file, current_app
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
|
||||
from werkzeug.utils import secure_filename
|
||||
import pandas as pd
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment, PatternFill
|
||||
from openpyxl.utils.dataframe import dataframe_to_rows
|
||||
from sqlalchemy import or_, and_
|
||||
from models import (
|
||||
db, TodoItem, TodoItemResponsible, TodoItemFollower,
|
||||
TodoAuditLog
|
||||
)
|
||||
from utils.logger import get_logger
|
||||
from utils.ldap_utils import validate_ad_accounts
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
excel_bp = Blueprint('excel', __name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# 允許的檔案類型
|
||||
ALLOWED_EXTENSIONS = {'xlsx', 'xls', 'csv'}
|
||||
|
||||
def allowed_file(filename):
|
||||
"""檢查檔案類型是否允許"""
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
def parse_date(date_str):
|
||||
"""解析日期字串"""
|
||||
if pd.isna(date_str) or not date_str:
|
||||
return None
|
||||
|
||||
if isinstance(date_str, datetime):
|
||||
return date_str.date()
|
||||
|
||||
if isinstance(date_str, date):
|
||||
return date_str
|
||||
|
||||
# 嘗試多種日期格式
|
||||
date_formats = ['%Y-%m-%d', '%Y/%m/%d', '%d/%m/%Y', '%m/%d/%Y', '%Y%m%d']
|
||||
for fmt in date_formats:
|
||||
try:
|
||||
return datetime.strptime(str(date_str), fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
@excel_bp.route('/upload', methods=['POST'])
|
||||
@jwt_required()
|
||||
def upload_excel():
|
||||
"""Upload and parse Excel file for todo import"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': '沒有選擇檔案'}), 400
|
||||
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
return jsonify({'error': '沒有選擇檔案'}), 400
|
||||
|
||||
if not allowed_file(file.filename):
|
||||
return jsonify({'error': '檔案類型不支援,請上傳 .xlsx, .xls 或 .csv 檔案'}), 400
|
||||
|
||||
# 儲存檔案到暫存目錄
|
||||
filename = secure_filename(file.filename)
|
||||
temp_dir = current_app.config.get('TEMP_FOLDER', tempfile.gettempdir())
|
||||
filepath = os.path.join(temp_dir, f"{uuid.uuid4()}_{filename}")
|
||||
file.save(filepath)
|
||||
|
||||
try:
|
||||
# 讀取 Excel/CSV 檔案
|
||||
if filename.endswith('.csv'):
|
||||
df = pd.read_csv(filepath, encoding='utf-8')
|
||||
else:
|
||||
df = pd.read_excel(filepath)
|
||||
|
||||
# 驗證必要欄位
|
||||
required_columns = ['標題', 'title'] # 支援中英文欄位名
|
||||
title_column = None
|
||||
for col in required_columns:
|
||||
if col in df.columns:
|
||||
title_column = col
|
||||
break
|
||||
|
||||
if not title_column:
|
||||
return jsonify({
|
||||
'error': '找不到必要欄位「標題」或「title」',
|
||||
'columns': list(df.columns)
|
||||
}), 400
|
||||
|
||||
# 解析資料
|
||||
todos_data = []
|
||||
errors = []
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
try:
|
||||
# 必要欄位
|
||||
title = str(row[title_column]).strip()
|
||||
if not title or title == 'nan':
|
||||
errors.append(f'第 {idx + 2} 行:標題不能為空')
|
||||
continue
|
||||
|
||||
# 選擇性欄位
|
||||
description = str(row.get('描述', row.get('description', ''))).strip()
|
||||
if description == 'nan':
|
||||
description = ''
|
||||
|
||||
# 狀態
|
||||
status_mapping = {
|
||||
'新建': 'NEW', '進行中': 'IN_PROGRESS', '完成': 'DONE',
|
||||
'NEW': 'NEW', 'IN_PROGRESS': 'IN_PROGRESS', 'DONE': 'DONE',
|
||||
'新': 'NEW', '進行': 'IN_PROGRESS', '完': 'DONE'
|
||||
}
|
||||
status_str = str(row.get('狀態', row.get('status', 'NEW'))).strip()
|
||||
status = status_mapping.get(status_str, 'NEW')
|
||||
|
||||
# 優先級
|
||||
priority_mapping = {
|
||||
'高': 'HIGH', '中': 'MEDIUM', '低': 'LOW',
|
||||
'HIGH': 'HIGH', 'MEDIUM': 'MEDIUM', 'LOW': 'LOW',
|
||||
'高優先級': 'HIGH', '中優先級': 'MEDIUM', '低優先級': 'LOW'
|
||||
}
|
||||
priority_str = str(row.get('優先級', row.get('priority', 'MEDIUM'))).strip()
|
||||
priority = priority_mapping.get(priority_str, 'MEDIUM')
|
||||
|
||||
# 到期日
|
||||
due_date = parse_date(row.get('到期日', row.get('due_date')))
|
||||
|
||||
# 負責人 (用分號或逗號分隔)
|
||||
responsible_str = str(row.get('負責人', row.get('responsible_users', ''))).strip()
|
||||
responsible_users = []
|
||||
if responsible_str and responsible_str != 'nan':
|
||||
responsible_users = [user.strip() for user in responsible_str.replace(',', ';').split(';') if user.strip()]
|
||||
|
||||
# 追蹤人
|
||||
followers_str = str(row.get('追蹤人', row.get('followers', ''))).strip()
|
||||
followers = []
|
||||
if followers_str and followers_str != 'nan':
|
||||
followers = [user.strip() for user in followers_str.replace(',', ';').split(';') if user.strip()]
|
||||
|
||||
todos_data.append({
|
||||
'row': idx + 2,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'status': status,
|
||||
'priority': priority,
|
||||
'due_date': due_date.isoformat() if due_date else None,
|
||||
'responsible_users': responsible_users,
|
||||
'followers': followers
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f'第 {idx + 2} 行解析錯誤: {str(e)}')
|
||||
|
||||
# 清理暫存檔案
|
||||
os.unlink(filepath)
|
||||
|
||||
return jsonify({
|
||||
'data': todos_data,
|
||||
'total': len(todos_data),
|
||||
'errors': errors,
|
||||
'columns': list(df.columns)
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
# 清理暫存檔案
|
||||
if os.path.exists(filepath):
|
||||
os.unlink(filepath)
|
||||
raise e
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Excel upload error: {str(e)}")
|
||||
return jsonify({'error': f'檔案處理失敗: {str(e)}'}), 500
|
||||
|
||||
@excel_bp.route('/import', methods=['POST'])
|
||||
@jwt_required()
|
||||
def import_todos():
|
||||
"""Import todos from parsed Excel data"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
claims = get_jwt()
|
||||
data = request.get_json()
|
||||
|
||||
todos_data = data.get('todos', [])
|
||||
if not todos_data:
|
||||
return jsonify({'error': '沒有要匯入的資料'}), 400
|
||||
|
||||
imported_count = 0
|
||||
errors = []
|
||||
|
||||
for todo_data in todos_data:
|
||||
try:
|
||||
# 驗證負責人和追蹤人的 AD 帳號
|
||||
responsible_users = todo_data.get('responsible_users', [])
|
||||
followers = todo_data.get('followers', [])
|
||||
|
||||
if responsible_users:
|
||||
valid_responsible = validate_ad_accounts(responsible_users)
|
||||
invalid_responsible = set(responsible_users) - set(valid_responsible.keys())
|
||||
if invalid_responsible:
|
||||
errors.append({
|
||||
'row': todo_data.get('row', '?'),
|
||||
'error': f'無效的負責人帳號: {", ".join(invalid_responsible)}'
|
||||
})
|
||||
continue
|
||||
|
||||
if followers:
|
||||
valid_followers = validate_ad_accounts(followers)
|
||||
invalid_followers = set(followers) - set(valid_followers.keys())
|
||||
if invalid_followers:
|
||||
errors.append({
|
||||
'row': todo_data.get('row', '?'),
|
||||
'error': f'無效的追蹤人帳號: {", ".join(invalid_followers)}'
|
||||
})
|
||||
continue
|
||||
|
||||
# 建立待辦事項
|
||||
due_date = None
|
||||
if todo_data.get('due_date'):
|
||||
due_date = datetime.strptime(todo_data['due_date'], '%Y-%m-%d').date()
|
||||
|
||||
todo = TodoItem(
|
||||
id=str(uuid.uuid4()),
|
||||
title=todo_data['title'],
|
||||
description=todo_data.get('description', ''),
|
||||
status=todo_data.get('status', 'NEW'),
|
||||
priority=todo_data.get('priority', 'MEDIUM'),
|
||||
due_date=due_date,
|
||||
creator_ad=identity,
|
||||
creator_display_name=claims.get('display_name', identity),
|
||||
creator_email=claims.get('email', ''),
|
||||
starred=False
|
||||
)
|
||||
db.session.add(todo)
|
||||
|
||||
# 新增負責人
|
||||
if responsible_users:
|
||||
for account in responsible_users:
|
||||
responsible = TodoItemResponsible(
|
||||
todo_id=todo.id,
|
||||
ad_account=account,
|
||||
added_by=identity
|
||||
)
|
||||
db.session.add(responsible)
|
||||
|
||||
# 新增追蹤人
|
||||
if followers:
|
||||
for account in followers:
|
||||
follower = TodoItemFollower(
|
||||
todo_id=todo.id,
|
||||
ad_account=account,
|
||||
added_by=identity
|
||||
)
|
||||
db.session.add(follower)
|
||||
|
||||
# 新增稽核記錄
|
||||
audit = TodoAuditLog(
|
||||
actor_ad=identity,
|
||||
todo_id=todo.id,
|
||||
action='CREATE',
|
||||
detail={
|
||||
'source': 'excel_import',
|
||||
'title': todo.title,
|
||||
'row': todo_data.get('row')
|
||||
}
|
||||
)
|
||||
db.session.add(audit)
|
||||
|
||||
imported_count += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append({
|
||||
'row': todo_data.get('row', '?'),
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Excel import completed: {imported_count} todos imported by {identity}")
|
||||
|
||||
return jsonify({
|
||||
'imported': imported_count,
|
||||
'errors': errors,
|
||||
'total_processed': len(todos_data)
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Excel import error: {str(e)}")
|
||||
return jsonify({'error': '匯入失敗'}), 500
|
||||
|
||||
@excel_bp.route('/export', methods=['GET'])
|
||||
@jwt_required()
|
||||
def export_todos():
|
||||
"""Export todos to Excel"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
# 篩選參數
|
||||
status = request.args.get('status')
|
||||
priority = request.args.get('priority')
|
||||
due_from = request.args.get('due_from')
|
||||
due_to = request.args.get('due_to')
|
||||
view_type = request.args.get('view', 'all')
|
||||
|
||||
# 查詢待辦事項
|
||||
query = TodoItem.query
|
||||
|
||||
# 套用檢視類型篩選
|
||||
if view_type == 'created':
|
||||
query = query.filter(TodoItem.creator_ad == identity)
|
||||
elif view_type == 'responsible':
|
||||
query = query.join(TodoItemResponsible).filter(
|
||||
TodoItemResponsible.ad_account == identity
|
||||
)
|
||||
elif view_type == 'following':
|
||||
query = query.join(TodoItemFollower).filter(
|
||||
TodoItemFollower.ad_account == identity
|
||||
)
|
||||
else: # all
|
||||
query = query.filter(
|
||||
or_(
|
||||
TodoItem.creator_ad == identity,
|
||||
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity),
|
||||
TodoItem.followers.any(TodoItemFollower.ad_account == identity)
|
||||
)
|
||||
)
|
||||
|
||||
# 套用其他篩選條件
|
||||
if status:
|
||||
query = query.filter(TodoItem.status == status)
|
||||
if priority:
|
||||
query = query.filter(TodoItem.priority == priority)
|
||||
if due_from:
|
||||
query = query.filter(TodoItem.due_date >= datetime.strptime(due_from, '%Y-%m-%d').date())
|
||||
if due_to:
|
||||
query = query.filter(TodoItem.due_date <= datetime.strptime(due_to, '%Y-%m-%d').date())
|
||||
|
||||
todos = query.order_by(TodoItem.created_at.desc()).all()
|
||||
|
||||
# 準備資料
|
||||
data = []
|
||||
for todo in todos:
|
||||
# 取得負責人和追蹤人
|
||||
responsible_users = [r.ad_account for r in todo.responsible_users]
|
||||
followers = [f.ad_account for f in todo.followers]
|
||||
|
||||
# 狀態和優先級的中文對應
|
||||
status_mapping = {'NEW': '新建', 'IN_PROGRESS': '進行中', 'DONE': '完成'}
|
||||
priority_mapping = {'HIGH': '高', 'MEDIUM': '中', 'LOW': '低'}
|
||||
|
||||
data.append({
|
||||
'編號': todo.id,
|
||||
'標題': todo.title,
|
||||
'描述': todo.description,
|
||||
'狀態': status_mapping.get(todo.status, todo.status),
|
||||
'優先級': priority_mapping.get(todo.priority, todo.priority),
|
||||
'到期日': todo.due_date.strftime('%Y-%m-%d') if todo.due_date else '',
|
||||
'建立者': todo.creator_ad,
|
||||
'建立時間': todo.created_at.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'完成時間': todo.completed_at.strftime('%Y-%m-%d %H:%M:%S') if todo.completed_at else '',
|
||||
'負責人': '; '.join(responsible_users),
|
||||
'追蹤人': '; '.join(followers),
|
||||
'星號標記': '是' if todo.starred else '否'
|
||||
})
|
||||
|
||||
# 建立 Excel 檔案
|
||||
df = pd.DataFrame(data)
|
||||
|
||||
# 建立暫存檔案
|
||||
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx')
|
||||
temp_filename = temp_file.name
|
||||
temp_file.close()
|
||||
|
||||
# 使用 openpyxl 建立更美觀的 Excel
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "待辦清單"
|
||||
|
||||
# 標題樣式
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||
header_alignment = Alignment(horizontal="center", vertical="center")
|
||||
|
||||
# 寫入標題
|
||||
if not df.empty:
|
||||
for r_idx, row in enumerate(dataframe_to_rows(df, index=False, header=True), 1):
|
||||
for c_idx, value in enumerate(row, 1):
|
||||
cell = ws.cell(row=r_idx, column=c_idx, value=value)
|
||||
if r_idx == 1: # 標題行
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = header_alignment
|
||||
|
||||
# 自動調整列寬
|
||||
for column in ws.columns:
|
||||
max_length = 0
|
||||
column_letter = column[0].column_letter
|
||||
for cell in column:
|
||||
try:
|
||||
if len(str(cell.value)) > max_length:
|
||||
max_length = len(str(cell.value))
|
||||
except:
|
||||
pass
|
||||
adjusted_width = min(max_length + 2, 50)
|
||||
ws.column_dimensions[column_letter].width = adjusted_width
|
||||
|
||||
wb.save(temp_filename)
|
||||
|
||||
# 產生檔案名稱
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"todos_{timestamp}.xlsx"
|
||||
|
||||
logger.info(f"Excel export: {len(todos)} todos exported by {identity}")
|
||||
|
||||
return send_file(
|
||||
temp_filename,
|
||||
as_attachment=True,
|
||||
download_name=filename,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Excel export error: {str(e)}")
|
||||
return jsonify({'error': '匯出失敗'}), 500
|
||||
|
||||
@excel_bp.route('/template', methods=['GET'])
|
||||
@jwt_required()
|
||||
def download_template():
|
||||
"""Download Excel import template"""
|
||||
try:
|
||||
# 建立範本資料
|
||||
template_data = {
|
||||
'標題': ['範例待辦事項1', '範例待辦事項2'],
|
||||
'描述': ['這是第一個範例的詳細描述', '這是第二個範例的詳細描述'],
|
||||
'狀態': ['新建', '進行中'],
|
||||
'優先級': ['高', '中'],
|
||||
'到期日': ['2024-12-31', '2025-01-15'],
|
||||
'負責人': ['user1@panjit.com.tw', 'user2@panjit.com.tw'],
|
||||
'追蹤人': ['user3@panjit.com.tw;user4@panjit.com.tw', 'user5@panjit.com.tw']
|
||||
}
|
||||
|
||||
# 說明資料
|
||||
instructions = {
|
||||
'欄位說明': [
|
||||
'標題 (必填)',
|
||||
'描述 (選填)',
|
||||
'狀態: 新建/進行中/完成',
|
||||
'優先級: 高/中/低',
|
||||
'到期日: YYYY-MM-DD 格式',
|
||||
'負責人: AD帳號,多人用分號分隔',
|
||||
'追蹤人: AD帳號,多人用分號分隔'
|
||||
],
|
||||
'說明': [
|
||||
'請填入待辦事項的標題',
|
||||
'可選填詳細描述',
|
||||
'可選填 NEW/IN_PROGRESS/DONE',
|
||||
'可選填 HIGH/MEDIUM/LOW',
|
||||
'例如: 2024-12-31',
|
||||
'例如: john@panjit.com.tw',
|
||||
'例如: mary@panjit.com.tw;tom@panjit.com.tw'
|
||||
]
|
||||
}
|
||||
|
||||
# 建立暫存檔案
|
||||
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx')
|
||||
temp_filename = temp_file.name
|
||||
temp_file.close()
|
||||
|
||||
# 建立 Excel 檔案
|
||||
wb = Workbook()
|
||||
|
||||
# 範本資料工作表
|
||||
ws_data = wb.active
|
||||
ws_data.title = "匯入範本"
|
||||
df_template = pd.DataFrame(template_data)
|
||||
|
||||
for r_idx, row in enumerate(dataframe_to_rows(df_template, index=False, header=True), 1):
|
||||
for c_idx, value in enumerate(row, 1):
|
||||
ws_data.cell(row=r_idx, column=c_idx, value=value)
|
||||
|
||||
# 說明工作表
|
||||
ws_help = wb.create_sheet("使用說明")
|
||||
df_help = pd.DataFrame(instructions)
|
||||
|
||||
for r_idx, row in enumerate(dataframe_to_rows(df_help, index=False, header=True), 1):
|
||||
for c_idx, value in enumerate(row, 1):
|
||||
ws_help.cell(row=r_idx, column=c_idx, value=value)
|
||||
|
||||
# 樣式設定
|
||||
for ws in [ws_data, ws_help]:
|
||||
for column in ws.columns:
|
||||
max_length = 0
|
||||
column_letter = column[0].column_letter
|
||||
for cell in column:
|
||||
try:
|
||||
if len(str(cell.value)) > max_length:
|
||||
max_length = len(str(cell.value))
|
||||
except:
|
||||
pass
|
||||
adjusted_width = min(max_length + 2, 50)
|
||||
ws.column_dimensions[column_letter].width = adjusted_width
|
||||
|
||||
wb.save(temp_filename)
|
||||
|
||||
logger.info(f"Template downloaded by {get_jwt_identity()}")
|
||||
|
||||
return send_file(
|
||||
temp_filename,
|
||||
as_attachment=True,
|
||||
download_name="todo_import_template.xlsx",
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Template download error: {str(e)}")
|
||||
return jsonify({'error': '範本下載失敗'}), 500
|
125
backend/routes/health.py
Normal file
125
backend/routes/health.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from flask import Blueprint, jsonify, current_app
|
||||
from datetime import datetime
|
||||
from models import db
|
||||
from utils.logger import get_logger
|
||||
import smtplib
|
||||
import redis
|
||||
|
||||
health_bp = Blueprint('health', __name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@health_bp.route('/healthz', methods=['GET'])
|
||||
def health_check():
|
||||
"""Basic health check"""
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
}), 200
|
||||
|
||||
@health_bp.route('/readiness', methods=['GET'])
|
||||
def readiness_check():
|
||||
"""Detailed readiness check"""
|
||||
try:
|
||||
checks = {
|
||||
'database': False,
|
||||
'ldap': False,
|
||||
'smtp': False,
|
||||
'redis': False
|
||||
}
|
||||
errors = []
|
||||
|
||||
# Check database
|
||||
try:
|
||||
db.session.execute(db.text('SELECT 1'))
|
||||
checks['database'] = True
|
||||
except Exception as e:
|
||||
errors.append(f"Database check failed: {str(e)}")
|
||||
logger.error(f"Database health check failed: {str(e)}")
|
||||
|
||||
# Check LDAP
|
||||
try:
|
||||
if current_app.config.get('USE_MOCK_LDAP', False):
|
||||
from utils.mock_ldap import test_ldap_connection
|
||||
else:
|
||||
from utils.ldap_utils import test_ldap_connection
|
||||
|
||||
if test_ldap_connection():
|
||||
checks['ldap'] = True
|
||||
else:
|
||||
errors.append("LDAP connection failed")
|
||||
except Exception as e:
|
||||
errors.append(f"LDAP check failed: {str(e)}")
|
||||
logger.error(f"LDAP health check failed: {str(e)}")
|
||||
|
||||
# Check SMTP
|
||||
try:
|
||||
from flask import current_app
|
||||
config = current_app.config
|
||||
|
||||
if config['SMTP_USE_SSL']:
|
||||
server = smtplib.SMTP_SSL(config['SMTP_SERVER'], config['SMTP_PORT'], timeout=5)
|
||||
else:
|
||||
server = smtplib.SMTP(config['SMTP_SERVER'], config['SMTP_PORT'], timeout=5)
|
||||
if config['SMTP_USE_TLS']:
|
||||
server.starttls()
|
||||
|
||||
server.quit()
|
||||
checks['smtp'] = True
|
||||
except Exception as e:
|
||||
errors.append(f"SMTP check failed: {str(e)}")
|
||||
logger.error(f"SMTP health check failed: {str(e)}")
|
||||
|
||||
# Check Redis
|
||||
try:
|
||||
from flask import current_app
|
||||
r = redis.from_url(current_app.config['REDIS_URL'])
|
||||
r.ping()
|
||||
checks['redis'] = True
|
||||
except Exception as e:
|
||||
errors.append(f"Redis check failed: {str(e)}")
|
||||
logger.error(f"Redis health check failed: {str(e)}")
|
||||
|
||||
# Determine overall status
|
||||
all_healthy = all(checks.values())
|
||||
critical_healthy = checks['database'] # Database is critical
|
||||
|
||||
if all_healthy:
|
||||
status_code = 200
|
||||
status = 'healthy'
|
||||
elif critical_healthy:
|
||||
status_code = 200
|
||||
status = 'degraded'
|
||||
else:
|
||||
status_code = 503
|
||||
status = 'unhealthy'
|
||||
|
||||
return jsonify({
|
||||
'status': status,
|
||||
'checks': checks,
|
||||
'errors': errors,
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
}), status_code
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Readiness check error: {str(e)}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error': str(e),
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
}), 503
|
||||
|
||||
@health_bp.route('/liveness', methods=['GET'])
|
||||
def liveness_check():
|
||||
"""Kubernetes liveness probe"""
|
||||
try:
|
||||
# Simple check to see if the app is running
|
||||
return jsonify({
|
||||
'status': 'alive',
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Liveness check failed: {str(e)}")
|
||||
return jsonify({
|
||||
'status': 'dead',
|
||||
'error': str(e)
|
||||
}), 503
|
584
backend/routes/notifications.py
Normal file
584
backend/routes/notifications.py
Normal file
@@ -0,0 +1,584 @@
|
||||
"""
|
||||
Notifications API Routes
|
||||
處理通知相關功能,包括 email 通知和系統通知
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
from datetime import datetime, date, timedelta
|
||||
from sqlalchemy import and_, or_
|
||||
from models import (
|
||||
db, TodoItem, TodoItemResponsible, TodoItemFollower,
|
||||
TodoUserPref, TodoAuditLog, TodoFireEmailLog
|
||||
)
|
||||
from utils.logger import get_logger
|
||||
from utils.email_service import EmailService
|
||||
from utils.notification_service import NotificationService
|
||||
import json
|
||||
|
||||
notifications_bp = Blueprint('notifications', __name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@notifications_bp.route('/', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_notifications():
|
||||
"""Get user notifications"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
# 獲取最近7天的相關通知 (指派、完成、逾期等)
|
||||
seven_days_ago = datetime.utcnow() - timedelta(days=7)
|
||||
|
||||
notifications = []
|
||||
|
||||
# 1. 獲取被指派的Todo (最近7天)
|
||||
assigned_todos = db.session.query(TodoItem).join(TodoItemResponsible).filter(
|
||||
and_(
|
||||
TodoItemResponsible.ad_account == identity,
|
||||
TodoItemResponsible.added_at >= seven_days_ago,
|
||||
TodoItemResponsible.added_by != identity # 不是自己指派給自己
|
||||
)
|
||||
).all()
|
||||
|
||||
logger.info(f"Found {len(assigned_todos)} assigned todos for user {identity}")
|
||||
|
||||
for todo in assigned_todos:
|
||||
responsible = next((r for r in todo.responsible_users if r.ad_account == identity), None)
|
||||
if responsible and responsible.added_by:
|
||||
notifications.append({
|
||||
'id': f"assign_{todo.id}_{int(responsible.added_at.timestamp())}",
|
||||
'type': 'assignment',
|
||||
'title': '新的待辦事項指派',
|
||||
'message': f'{responsible.added_by} 指派了「{todo.title}」給您',
|
||||
'time': responsible.added_at.strftime('%m/%d %H:%M'),
|
||||
'read': False,
|
||||
'actionable': True,
|
||||
'todo_id': todo.id
|
||||
})
|
||||
|
||||
# 2. 獲取即將到期的Todo (明後天)
|
||||
tomorrow = date.today() + timedelta(days=1)
|
||||
day_after_tomorrow = date.today() + timedelta(days=2)
|
||||
|
||||
due_soon_todos = db.session.query(TodoItem).filter(
|
||||
and_(
|
||||
or_(
|
||||
TodoItem.creator_ad == identity,
|
||||
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity)
|
||||
),
|
||||
TodoItem.due_date.in_([tomorrow, day_after_tomorrow]),
|
||||
TodoItem.status != 'DONE'
|
||||
)
|
||||
).all()
|
||||
|
||||
for todo in due_soon_todos:
|
||||
days_until_due = (todo.due_date - date.today()).days
|
||||
notifications.append({
|
||||
'id': f"due_{todo.id}_{todo.due_date}",
|
||||
'type': 'reminder',
|
||||
'title': '待辦事項即將到期',
|
||||
'message': f'「{todo.title}」將在{days_until_due}天後到期',
|
||||
'time': f'{todo.due_date.strftime("%m/%d")} 到期',
|
||||
'read': False,
|
||||
'actionable': True,
|
||||
'todo_id': todo.id
|
||||
})
|
||||
|
||||
# 3. 獲取逾期的Todo
|
||||
overdue_todos = db.session.query(TodoItem).filter(
|
||||
and_(
|
||||
or_(
|
||||
TodoItem.creator_ad == identity,
|
||||
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity)
|
||||
),
|
||||
TodoItem.due_date < date.today(),
|
||||
TodoItem.status != 'DONE'
|
||||
)
|
||||
).all()
|
||||
|
||||
for todo in overdue_todos:
|
||||
days_overdue = (date.today() - todo.due_date).days
|
||||
notifications.append({
|
||||
'id': f"overdue_{todo.id}_{todo.due_date}",
|
||||
'type': 'overdue',
|
||||
'title': '待辦事項已逾期',
|
||||
'message': f'「{todo.title}」已逾期{days_overdue}天',
|
||||
'time': f'逾期 {days_overdue} 天',
|
||||
'read': False,
|
||||
'actionable': True,
|
||||
'todo_id': todo.id
|
||||
})
|
||||
|
||||
# 按時間排序 (最新在前)
|
||||
notifications.sort(key=lambda x: x['time'], reverse=True)
|
||||
|
||||
return jsonify({
|
||||
'notifications': notifications,
|
||||
'unread_count': len(notifications)
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching notifications: {str(e)}")
|
||||
return jsonify({'error': '獲取通知失敗'}), 500
|
||||
|
||||
@notifications_bp.route('/fire-email', methods=['POST'])
|
||||
@jwt_required()
|
||||
def send_fire_email():
|
||||
"""Send urgent fire email notification"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
data = request.get_json()
|
||||
|
||||
todo_id = data.get('todo_id')
|
||||
custom_message = data.get('message', '')
|
||||
|
||||
if not todo_id:
|
||||
return jsonify({'error': '待辦事項ID不能為空'}), 400
|
||||
|
||||
# 檢查待辦事項
|
||||
todo = TodoItem.query.filter_by(id=todo_id).first()
|
||||
if not todo:
|
||||
return jsonify({'error': '找不到待辦事項'}), 404
|
||||
|
||||
# 檢查權限 (只有建立者或負責人可以發送 fire email)
|
||||
if not (todo.creator_ad == identity or
|
||||
any(r.ad_account == identity for r in todo.responsible_users)):
|
||||
return jsonify({'error': '沒有權限發送緊急通知'}), 403
|
||||
|
||||
# 檢查用戶 fire email 配額
|
||||
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
|
||||
if not user_pref:
|
||||
return jsonify({'error': '找不到使用者設定'}), 404
|
||||
|
||||
# 檢查今日配額
|
||||
today = date.today()
|
||||
if user_pref.fire_email_last_reset != today:
|
||||
user_pref.fire_email_today_count = 0
|
||||
user_pref.fire_email_last_reset = today
|
||||
|
||||
daily_limit = current_app.config.get('FIRE_EMAIL_DAILY_LIMIT', 3)
|
||||
if user_pref.fire_email_today_count >= daily_limit:
|
||||
return jsonify({
|
||||
'error': f'今日緊急通知配額已用完 ({daily_limit}次)',
|
||||
'quota_exceeded': True
|
||||
}), 429
|
||||
|
||||
# 檢查2分鐘冷卻機制
|
||||
cooldown_minutes = current_app.config.get('FIRE_EMAIL_COOLDOWN_MINUTES', 2)
|
||||
last_fire_log = TodoFireEmailLog.query.filter_by(
|
||||
todo_id=todo_id
|
||||
).order_by(TodoFireEmailLog.sent_at.desc()).first()
|
||||
|
||||
if last_fire_log:
|
||||
time_since_last = datetime.utcnow() - last_fire_log.sent_at
|
||||
if time_since_last.total_seconds() < cooldown_minutes * 60:
|
||||
remaining_seconds = int(cooldown_minutes * 60 - time_since_last.total_seconds())
|
||||
return jsonify({
|
||||
'error': f'此待辦事項的緊急通知需要冷卻 {remaining_seconds} 秒後才能再次發送',
|
||||
'cooldown_remaining': remaining_seconds
|
||||
}), 429
|
||||
|
||||
# 準備收件人清單
|
||||
recipients = set()
|
||||
|
||||
# 加入所有負責人
|
||||
for responsible in todo.responsible_users:
|
||||
recipients.add(responsible.ad_account)
|
||||
|
||||
# 加入所有追蹤人
|
||||
for follower in todo.followers:
|
||||
recipients.add(follower.ad_account)
|
||||
|
||||
# 如果是建立者發送,不包含自己
|
||||
recipients.discard(identity)
|
||||
|
||||
if not recipients:
|
||||
# 檢查是否只有發送者自己是相關人員
|
||||
all_related_users = set()
|
||||
for responsible in todo.responsible_users:
|
||||
all_related_users.add(responsible.ad_account)
|
||||
for follower in todo.followers:
|
||||
all_related_users.add(follower.ad_account)
|
||||
|
||||
if len(all_related_users) == 1 and identity in all_related_users:
|
||||
return jsonify({'error': '無法發送緊急通知:您是此待辦事項的唯一相關人員,請先指派其他負責人或追蹤人'}), 400
|
||||
else:
|
||||
return jsonify({'error': '沒有找到收件人'}), 400
|
||||
|
||||
# 發送郵件
|
||||
email_service = EmailService()
|
||||
success_count = 0
|
||||
failed_recipients = []
|
||||
|
||||
for recipient in recipients:
|
||||
try:
|
||||
# 檢查收件人是否啟用郵件通知
|
||||
recipient_pref = TodoUserPref.query.filter_by(ad_account=recipient).first()
|
||||
if recipient_pref and not recipient_pref.email_reminder_enabled:
|
||||
continue
|
||||
|
||||
success = email_service.send_fire_email(
|
||||
todo=todo,
|
||||
recipient=recipient,
|
||||
sender=identity,
|
||||
custom_message=custom_message
|
||||
)
|
||||
|
||||
if success:
|
||||
success_count += 1
|
||||
else:
|
||||
failed_recipients.append(recipient)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send fire email to {recipient}: {str(e)}")
|
||||
failed_recipients.append(recipient)
|
||||
|
||||
# 更新配額
|
||||
user_pref.fire_email_today_count += 1
|
||||
|
||||
# 記錄 Fire Email 發送日誌 (用於冷卻檢查)
|
||||
if success_count > 0:
|
||||
fire_log = TodoFireEmailLog(
|
||||
todo_id=todo_id,
|
||||
sender_ad=identity
|
||||
)
|
||||
db.session.add(fire_log)
|
||||
|
||||
# 記錄稽核日誌
|
||||
audit = TodoAuditLog(
|
||||
actor_ad=identity,
|
||||
todo_id=todo_id,
|
||||
action='FIRE_EMAIL',
|
||||
detail={
|
||||
'recipients_count': len(recipients),
|
||||
'success_count': success_count,
|
||||
'failed_count': len(failed_recipients),
|
||||
'custom_message': custom_message[:100] if custom_message else None
|
||||
}
|
||||
)
|
||||
db.session.add(audit)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Fire email sent by {identity} for todo {todo_id}: {success_count}/{len(recipients)} successful")
|
||||
|
||||
return jsonify({
|
||||
'sent': success_count,
|
||||
'total_recipients': len(recipients),
|
||||
'failed_recipients': failed_recipients,
|
||||
'remaining_quota': max(0, daily_limit - user_pref.fire_email_today_count)
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Fire email error: {str(e)}")
|
||||
return jsonify({'error': '發送緊急通知失敗'}), 500
|
||||
|
||||
@notifications_bp.route('/digest', methods=['POST'])
|
||||
@jwt_required()
|
||||
def send_digest():
|
||||
"""Send digest email to user"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
data = request.get_json()
|
||||
|
||||
digest_type = data.get('type', 'weekly') # daily, weekly, monthly
|
||||
|
||||
# 檢查使用者偏好
|
||||
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
|
||||
if not user_pref or not user_pref.email_reminder_enabled:
|
||||
return jsonify({'error': '郵件通知未啟用'}), 400
|
||||
|
||||
# 準備摘要資料
|
||||
notification_service = NotificationService()
|
||||
digest_data = notification_service.prepare_digest(identity, digest_type)
|
||||
|
||||
# 發送摘要郵件
|
||||
email_service = EmailService()
|
||||
success = email_service.send_digest_email(identity, digest_data)
|
||||
|
||||
if success:
|
||||
# 記錄稽核日誌
|
||||
audit = TodoAuditLog(
|
||||
actor_ad=identity,
|
||||
todo_id=None,
|
||||
action='DIGEST_EMAIL',
|
||||
detail={'type': digest_type}
|
||||
)
|
||||
db.session.add(audit)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Digest email sent to {identity}: {digest_type}")
|
||||
return jsonify({'message': '摘要郵件已發送'}), 200
|
||||
else:
|
||||
return jsonify({'error': '摘要郵件發送失敗'}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Digest email error: {str(e)}")
|
||||
return jsonify({'error': '摘要郵件發送失敗'}), 500
|
||||
|
||||
@notifications_bp.route('/reminders/send', methods=['POST'])
|
||||
@jwt_required()
|
||||
def send_reminders():
|
||||
"""Send reminder emails for due/overdue todos"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
# 管理員權限檢查 (簡化版本,實際應該檢查 AD 群組)
|
||||
# TODO: 實作適當的管理員權限檢查
|
||||
|
||||
# 查找需要提醒的待辦事項
|
||||
today = date.today()
|
||||
tomorrow = today + timedelta(days=1)
|
||||
|
||||
# 即將到期的待辦事項 (明天到期)
|
||||
due_tomorrow = db.session.query(TodoItem).filter(
|
||||
and_(
|
||||
TodoItem.due_date == tomorrow,
|
||||
TodoItem.status != 'DONE'
|
||||
)
|
||||
).all()
|
||||
|
||||
# 已逾期的待辦事項
|
||||
overdue = db.session.query(TodoItem).filter(
|
||||
and_(
|
||||
TodoItem.due_date < today,
|
||||
TodoItem.status != 'DONE'
|
||||
)
|
||||
).all()
|
||||
|
||||
email_service = EmailService()
|
||||
notification_service = NotificationService()
|
||||
|
||||
sent_count = 0
|
||||
|
||||
# 處理即將到期的提醒
|
||||
for todo in due_tomorrow:
|
||||
recipients = notification_service.get_notification_recipients(todo)
|
||||
for recipient in recipients:
|
||||
try:
|
||||
if email_service.send_reminder_email(todo, recipient, 'due_tomorrow'):
|
||||
sent_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send due tomorrow reminder to {recipient}: {str(e)}")
|
||||
|
||||
# 處理逾期提醒
|
||||
for todo in overdue:
|
||||
recipients = notification_service.get_notification_recipients(todo)
|
||||
for recipient in recipients:
|
||||
try:
|
||||
if email_service.send_reminder_email(todo, recipient, 'overdue'):
|
||||
sent_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send overdue reminder to {recipient}: {str(e)}")
|
||||
|
||||
# 記錄稽核日誌
|
||||
audit = TodoAuditLog(
|
||||
actor_ad=identity,
|
||||
todo_id=None,
|
||||
action='BULK_REMINDER',
|
||||
detail={
|
||||
'due_tomorrow_count': len(due_tomorrow),
|
||||
'overdue_count': len(overdue),
|
||||
'emails_sent': sent_count
|
||||
}
|
||||
)
|
||||
db.session.add(audit)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Reminders sent by {identity}: {sent_count} emails sent")
|
||||
|
||||
return jsonify({
|
||||
'emails_sent': sent_count,
|
||||
'due_tomorrow': len(due_tomorrow),
|
||||
'overdue': len(overdue)
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Bulk reminder error: {str(e)}")
|
||||
return jsonify({'error': '批量提醒發送失敗'}), 500
|
||||
|
||||
@notifications_bp.route('/settings', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_notification_settings():
|
||||
"""Get user notification settings"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
|
||||
if not user_pref:
|
||||
return jsonify({'error': '找不到使用者設定'}), 404
|
||||
|
||||
settings = {
|
||||
'email_reminder_enabled': user_pref.email_reminder_enabled,
|
||||
'notification_enabled': user_pref.notification_enabled,
|
||||
'weekly_summary_enabled': user_pref.weekly_summary_enabled,
|
||||
'monthly_summary_enabled': getattr(user_pref, 'monthly_summary_enabled', False),
|
||||
'reminder_days_before': getattr(user_pref, 'reminder_days_before', [1, 3]),
|
||||
'daily_summary_time': getattr(user_pref, 'daily_summary_time', '09:00'),
|
||||
'weekly_summary_time': getattr(user_pref, 'weekly_summary_time', '09:00'),
|
||||
'monthly_summary_time': getattr(user_pref, 'monthly_summary_time', '09:00'),
|
||||
'weekly_summary_day': getattr(user_pref, 'weekly_summary_day', 1),
|
||||
'monthly_summary_day': getattr(user_pref, 'monthly_summary_day', 1),
|
||||
'fire_email_quota': {
|
||||
'used_today': user_pref.fire_email_today_count,
|
||||
'daily_limit': current_app.config.get('FIRE_EMAIL_DAILY_LIMIT', 3),
|
||||
'last_reset': user_pref.fire_email_last_reset.isoformat() if user_pref.fire_email_last_reset else None
|
||||
}
|
||||
}
|
||||
|
||||
return jsonify(settings), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching notification settings: {str(e)}")
|
||||
return jsonify({'error': '取得通知設定失敗'}), 500
|
||||
|
||||
@notifications_bp.route('/settings', methods=['PATCH'])
|
||||
@jwt_required()
|
||||
def update_notification_settings():
|
||||
"""Update user notification settings"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
data = request.get_json()
|
||||
|
||||
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
|
||||
if not user_pref:
|
||||
return jsonify({'error': '找不到使用者設定'}), 404
|
||||
|
||||
# 更新允許的欄位
|
||||
if 'email_reminder_enabled' in data:
|
||||
user_pref.email_reminder_enabled = bool(data['email_reminder_enabled'])
|
||||
|
||||
if 'notification_enabled' in data:
|
||||
user_pref.notification_enabled = bool(data['notification_enabled'])
|
||||
|
||||
if 'weekly_summary_enabled' in data:
|
||||
user_pref.weekly_summary_enabled = bool(data['weekly_summary_enabled'])
|
||||
|
||||
if 'monthly_summary_enabled' in data:
|
||||
user_pref.monthly_summary_enabled = bool(data['monthly_summary_enabled'])
|
||||
|
||||
if 'reminder_days_before' in data and isinstance(data['reminder_days_before'], list):
|
||||
user_pref.reminder_days_before = data['reminder_days_before']
|
||||
|
||||
if 'weekly_summary_time' in data:
|
||||
user_pref.weekly_summary_time = str(data['weekly_summary_time'])
|
||||
|
||||
if 'monthly_summary_time' in data:
|
||||
user_pref.monthly_summary_time = str(data['monthly_summary_time'])
|
||||
|
||||
if 'weekly_summary_day' in data:
|
||||
user_pref.weekly_summary_day = int(data['weekly_summary_day'])
|
||||
|
||||
if 'monthly_summary_day' in data:
|
||||
user_pref.monthly_summary_day = int(data['monthly_summary_day'])
|
||||
|
||||
user_pref.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Notification settings updated for {identity}")
|
||||
|
||||
return jsonify({'message': '通知設定已更新'}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Error updating notification settings: {str(e)}")
|
||||
return jsonify({'error': '更新通知設定失敗'}), 500
|
||||
|
||||
@notifications_bp.route('/test', methods=['POST'])
|
||||
@jwt_required()
|
||||
def test_notification():
|
||||
"""Send test notification email"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
data = request.get_json() or {}
|
||||
|
||||
# 檢查是否有直接指定的郵件地址
|
||||
recipient_email = data.get('recipient_email')
|
||||
|
||||
email_service = EmailService()
|
||||
|
||||
if recipient_email:
|
||||
# 直接發送到指定郵件地址
|
||||
success = email_service.send_test_email_direct(recipient_email)
|
||||
recipient_info = recipient_email
|
||||
else:
|
||||
# 使用 AD 帳號查詢
|
||||
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
|
||||
if not user_pref:
|
||||
return jsonify({'error': '找不到使用者設定'}), 404
|
||||
|
||||
success = email_service.send_test_email(identity)
|
||||
recipient_info = identity
|
||||
|
||||
if success:
|
||||
# 記錄稽核日誌
|
||||
audit = TodoAuditLog(
|
||||
actor_ad=identity,
|
||||
todo_id=None,
|
||||
action='MAIL_SENT',
|
||||
detail={'recipient': recipient_info, 'type': 'test_email'}
|
||||
)
|
||||
db.session.add(audit)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Test email sent to {recipient_info}")
|
||||
return jsonify({'message': '測試郵件已發送'}), 200
|
||||
else:
|
||||
return jsonify({'error': '測試郵件發送失敗'}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Test email error: {str(e)}")
|
||||
return jsonify({'error': '測試郵件發送失敗'}), 500
|
||||
|
||||
@notifications_bp.route('/mark-read', methods=['POST'])
|
||||
@jwt_required()
|
||||
def mark_notification_read():
|
||||
"""Mark single notification as read"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
data = request.get_json()
|
||||
|
||||
notification_id = data.get('notification_id')
|
||||
if not notification_id:
|
||||
return jsonify({'error': '通知ID不能為空'}), 400
|
||||
|
||||
# 這裡可以實作將已讀狀態存在 Redis 或 database 中
|
||||
# 暫時返回成功,實際可以儲存在用戶的已讀列表中
|
||||
logger.info(f"Marked notification {notification_id} as read for user {identity}")
|
||||
|
||||
return jsonify({'message': '已標記為已讀'}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Mark notification read error: {str(e)}")
|
||||
return jsonify({'error': '標記已讀失敗'}), 500
|
||||
|
||||
@notifications_bp.route('/mark-all-read', methods=['POST'])
|
||||
@jwt_required()
|
||||
def mark_all_notifications_read():
|
||||
"""Mark all notifications as read"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
# 這裡可以實作將所有通知標記為已讀
|
||||
# 暫時返回成功
|
||||
logger.info(f"Marked all notifications as read for user {identity}")
|
||||
|
||||
return jsonify({'message': '已將所有通知標記為已讀'}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Mark all notifications read error: {str(e)}")
|
||||
return jsonify({'error': '標記全部已讀失敗'}), 500
|
||||
|
||||
@notifications_bp.route('/view-todo/<todo_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def view_todo_from_notification():
|
||||
"""Get todo details from notification click"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
# 這裡暫時返回成功,前端可以導航到對應的 todo
|
||||
return jsonify({'message': '導航到待辦事項'}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"View todo from notification error: {str(e)}")
|
||||
return jsonify({'error': '查看待辦事項失敗'}), 500
|
372
backend/routes/reports.py
Normal file
372
backend/routes/reports.py
Normal file
@@ -0,0 +1,372 @@
|
||||
"""
|
||||
Reports API Routes
|
||||
提供待辦清單的統計報表和分析
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
from datetime import datetime, date, timedelta
|
||||
from sqlalchemy import func, and_, or_
|
||||
from models import (
|
||||
db, TodoItem, TodoItemResponsible, TodoItemFollower,
|
||||
TodoAuditLog, TodoUserPref
|
||||
)
|
||||
from utils.logger import get_logger
|
||||
import calendar
|
||||
|
||||
reports_bp = Blueprint('reports', __name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@reports_bp.route('/summary', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_summary():
|
||||
"""Get user's todo summary"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
# Count todos by status for current user
|
||||
query = TodoItem.query.filter(
|
||||
or_(
|
||||
TodoItem.creator_ad == identity,
|
||||
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity),
|
||||
TodoItem.followers.any(TodoItemFollower.ad_account == identity)
|
||||
)
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
completed = query.filter(TodoItem.status == 'DONE').count()
|
||||
in_progress = query.filter(TodoItem.status == 'IN_PROGRESS').count()
|
||||
new = query.filter(TodoItem.status == 'NEW').count()
|
||||
|
||||
# Overdue todos
|
||||
today = date.today()
|
||||
overdue = query.filter(
|
||||
and_(
|
||||
TodoItem.due_date < today,
|
||||
TodoItem.status != 'DONE'
|
||||
)
|
||||
).count()
|
||||
|
||||
# Due today
|
||||
due_today = query.filter(
|
||||
and_(
|
||||
TodoItem.due_date == today,
|
||||
TodoItem.status != 'DONE'
|
||||
)
|
||||
).count()
|
||||
|
||||
# Due this week
|
||||
week_end = today + timedelta(days=7)
|
||||
due_this_week = query.filter(
|
||||
and_(
|
||||
TodoItem.due_date.between(today, week_end),
|
||||
TodoItem.status != 'DONE'
|
||||
)
|
||||
).count()
|
||||
|
||||
# Priority distribution
|
||||
high_priority = query.filter(TodoItem.priority == 'HIGH').count()
|
||||
medium_priority = query.filter(TodoItem.priority == 'MEDIUM').count()
|
||||
low_priority = query.filter(TodoItem.priority == 'LOW').count()
|
||||
|
||||
# Completion rate
|
||||
completion_rate = (completed / total * 100) if total > 0 else 0
|
||||
|
||||
return jsonify({
|
||||
'summary': {
|
||||
'total': total,
|
||||
'completed': completed,
|
||||
'in_progress': in_progress,
|
||||
'new': new,
|
||||
'overdue': overdue,
|
||||
'due_today': due_today,
|
||||
'due_this_week': due_this_week,
|
||||
'completion_rate': round(completion_rate, 1)
|
||||
},
|
||||
'priority_distribution': {
|
||||
'high': high_priority,
|
||||
'medium': medium_priority,
|
||||
'low': low_priority
|
||||
}
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching summary: {str(e)}")
|
||||
return jsonify({'error': 'Failed to fetch summary'}), 500
|
||||
|
||||
@reports_bp.route('/activity', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_activity():
|
||||
"""Get user's activity over time"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
days = request.args.get('days', 30, type=int)
|
||||
|
||||
# Get date range
|
||||
end_date = date.today()
|
||||
start_date = end_date - timedelta(days=days-1)
|
||||
|
||||
# Query audit logs for the user
|
||||
logs = db.session.query(
|
||||
func.date(TodoAuditLog.timestamp).label('date'),
|
||||
func.count(TodoAuditLog.id).label('count'),
|
||||
TodoAuditLog.action
|
||||
).filter(
|
||||
and_(
|
||||
TodoAuditLog.actor_ad == identity,
|
||||
func.date(TodoAuditLog.timestamp) >= start_date
|
||||
)
|
||||
).group_by(
|
||||
func.date(TodoAuditLog.timestamp),
|
||||
TodoAuditLog.action
|
||||
).all()
|
||||
|
||||
# Organize by date and action
|
||||
activity_data = {}
|
||||
for log in logs:
|
||||
date_str = log.date.isoformat()
|
||||
if date_str not in activity_data:
|
||||
activity_data[date_str] = {'CREATE': 0, 'UPDATE': 0, 'DELETE': 0}
|
||||
activity_data[date_str][log.action] = log.count
|
||||
|
||||
# Fill in missing dates
|
||||
current_date = start_date
|
||||
while current_date <= end_date:
|
||||
date_str = current_date.isoformat()
|
||||
if date_str not in activity_data:
|
||||
activity_data[date_str] = {'CREATE': 0, 'UPDATE': 0, 'DELETE': 0}
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
return jsonify({
|
||||
'activity': activity_data,
|
||||
'period': {
|
||||
'start_date': start_date.isoformat(),
|
||||
'end_date': end_date.isoformat(),
|
||||
'days': days
|
||||
}
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching activity: {str(e)}")
|
||||
return jsonify({'error': 'Failed to fetch activity'}), 500
|
||||
|
||||
@reports_bp.route('/productivity', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_productivity():
|
||||
"""Get productivity metrics"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
# Get date ranges
|
||||
today = date.today()
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
month_start = today.replace(day=1)
|
||||
|
||||
# Base query for user's todos
|
||||
base_query = TodoItem.query.filter(
|
||||
or_(
|
||||
TodoItem.creator_ad == identity,
|
||||
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity)
|
||||
)
|
||||
)
|
||||
|
||||
# Today's completions
|
||||
today_completed = base_query.filter(
|
||||
and_(
|
||||
func.date(TodoItem.completed_at) == today,
|
||||
TodoItem.status == 'DONE'
|
||||
)
|
||||
).count()
|
||||
|
||||
# This week's completions
|
||||
week_completed = base_query.filter(
|
||||
and_(
|
||||
func.date(TodoItem.completed_at) >= week_start,
|
||||
TodoItem.status == 'DONE'
|
||||
)
|
||||
).count()
|
||||
|
||||
# This month's completions
|
||||
month_completed = base_query.filter(
|
||||
and_(
|
||||
func.date(TodoItem.completed_at) >= month_start,
|
||||
TodoItem.status == 'DONE'
|
||||
)
|
||||
).count()
|
||||
|
||||
# Average completion time (for completed todos)
|
||||
completed_todos = base_query.filter(
|
||||
and_(
|
||||
TodoItem.status == 'DONE',
|
||||
TodoItem.completed_at.isnot(None)
|
||||
)
|
||||
).all()
|
||||
|
||||
avg_completion_days = 0
|
||||
if completed_todos:
|
||||
total_days = 0
|
||||
count = 0
|
||||
for todo in completed_todos:
|
||||
if todo.completed_at and todo.created_at:
|
||||
days = (todo.completed_at.date() - todo.created_at.date()).days
|
||||
total_days += days
|
||||
count += 1
|
||||
avg_completion_days = round(total_days / count, 1) if count > 0 else 0
|
||||
|
||||
# On-time completion rate (within due date)
|
||||
on_time_todos = base_query.filter(
|
||||
and_(
|
||||
TodoItem.status == 'DONE',
|
||||
TodoItem.due_date.isnot(None),
|
||||
TodoItem.completed_at.isnot(None),
|
||||
func.date(TodoItem.completed_at) <= TodoItem.due_date
|
||||
)
|
||||
).count()
|
||||
|
||||
total_due_todos = base_query.filter(
|
||||
and_(
|
||||
TodoItem.status == 'DONE',
|
||||
TodoItem.due_date.isnot(None)
|
||||
)
|
||||
).count()
|
||||
|
||||
on_time_rate = (on_time_todos / total_due_todos * 100) if total_due_todos > 0 else 0
|
||||
|
||||
return jsonify({
|
||||
'productivity': {
|
||||
'today_completed': today_completed,
|
||||
'week_completed': week_completed,
|
||||
'month_completed': month_completed,
|
||||
'avg_completion_days': avg_completion_days,
|
||||
'on_time_rate': round(on_time_rate, 1),
|
||||
'total_with_due_dates': total_due_todos,
|
||||
'on_time_count': on_time_todos
|
||||
}
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching productivity: {str(e)}")
|
||||
return jsonify({'error': 'Failed to fetch productivity metrics'}), 500
|
||||
|
||||
@reports_bp.route('/team-overview', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_team_overview():
|
||||
"""Get team overview for todos created by current user"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
# Get todos created by current user
|
||||
created_todos = TodoItem.query.filter(TodoItem.creator_ad == identity)
|
||||
|
||||
# Get unique responsible users from these todos
|
||||
responsible_stats = db.session.query(
|
||||
TodoItemResponsible.ad_account,
|
||||
func.count(TodoItem.id).label('total'),
|
||||
func.sum(func.case([(TodoItem.status == 'DONE', 1)], else_=0)).label('completed'),
|
||||
func.sum(func.case([(TodoItem.status == 'IN_PROGRESS', 1)], else_=0)).label('in_progress'),
|
||||
func.sum(func.case([
|
||||
(and_(TodoItem.due_date < date.today(), TodoItem.status != 'DONE'), 1)
|
||||
], else_=0)).label('overdue')
|
||||
).join(
|
||||
TodoItem, TodoItemResponsible.todo_id == TodoItem.id
|
||||
).filter(
|
||||
TodoItem.creator_ad == identity
|
||||
).group_by(
|
||||
TodoItemResponsible.ad_account
|
||||
).all()
|
||||
|
||||
team_stats = []
|
||||
for stat in responsible_stats:
|
||||
completion_rate = (stat.completed / stat.total * 100) if stat.total > 0 else 0
|
||||
team_stats.append({
|
||||
'ad_account': stat.ad_account,
|
||||
'total_assigned': stat.total,
|
||||
'completed': stat.completed,
|
||||
'in_progress': stat.in_progress,
|
||||
'overdue': stat.overdue,
|
||||
'completion_rate': round(completion_rate, 1)
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'team_overview': team_stats,
|
||||
'summary': {
|
||||
'total_team_members': len(team_stats),
|
||||
'total_assigned_todos': sum(stat['total_assigned'] for stat in team_stats),
|
||||
'total_completed': sum(stat['completed'] for stat in team_stats),
|
||||
'total_overdue': sum(stat['overdue'] for stat in team_stats)
|
||||
}
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching team overview: {str(e)}")
|
||||
return jsonify({'error': 'Failed to fetch team overview'}), 500
|
||||
|
||||
@reports_bp.route('/monthly-trends', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_monthly_trends():
|
||||
"""Get monthly trends for the past year"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
months = request.args.get('months', 12, type=int)
|
||||
|
||||
# Calculate date range
|
||||
today = date.today()
|
||||
start_date = today.replace(day=1) - timedelta(days=30 * (months - 1))
|
||||
|
||||
# Base query
|
||||
base_query = TodoItem.query.filter(
|
||||
or_(
|
||||
TodoItem.creator_ad == identity,
|
||||
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity)
|
||||
)
|
||||
)
|
||||
|
||||
# Get monthly statistics
|
||||
monthly_data = db.session.query(
|
||||
func.year(TodoItem.created_at).label('year'),
|
||||
func.month(TodoItem.created_at).label('month'),
|
||||
func.count(TodoItem.id).label('created'),
|
||||
func.sum(func.case([(TodoItem.status == 'DONE', 1)], else_=0)).label('completed')
|
||||
).filter(
|
||||
and_(
|
||||
func.date(TodoItem.created_at) >= start_date,
|
||||
or_(
|
||||
TodoItem.creator_ad == identity,
|
||||
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity)
|
||||
)
|
||||
)
|
||||
).group_by(
|
||||
func.year(TodoItem.created_at),
|
||||
func.month(TodoItem.created_at)
|
||||
).order_by(
|
||||
func.year(TodoItem.created_at),
|
||||
func.month(TodoItem.created_at)
|
||||
).all()
|
||||
|
||||
# Format the data
|
||||
trends = []
|
||||
for data in monthly_data:
|
||||
month_name = calendar.month_name[data.month]
|
||||
completion_rate = (data.completed / data.created * 100) if data.created > 0 else 0
|
||||
|
||||
trends.append({
|
||||
'year': data.year,
|
||||
'month': data.month,
|
||||
'month_name': month_name,
|
||||
'created': data.created,
|
||||
'completed': data.completed,
|
||||
'completion_rate': round(completion_rate, 1)
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'trends': trends,
|
||||
'period': {
|
||||
'months': months,
|
||||
'start_date': start_date.isoformat(),
|
||||
'end_date': today.isoformat()
|
||||
}
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching monthly trends: {str(e)}")
|
||||
return jsonify({'error': 'Failed to fetch monthly trends'}), 500
|
261
backend/routes/scheduler.py
Normal file
261
backend/routes/scheduler.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
Scheduler API Routes
|
||||
處理排程任務的管理和監控功能
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
from datetime import datetime, date, timedelta
|
||||
from sqlalchemy import and_, or_
|
||||
from models import (
|
||||
db, TodoItem, TodoItemResponsible, TodoItemFollower,
|
||||
TodoUserPref, TodoAuditLog
|
||||
)
|
||||
from utils.logger import get_logger
|
||||
from utils.email_service import EmailService
|
||||
from utils.notification_service import NotificationService
|
||||
from tasks_simple import send_daily_reminders, send_weekly_summary, cleanup_old_logs
|
||||
import json
|
||||
|
||||
scheduler_bp = Blueprint('scheduler', __name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@scheduler_bp.route('/trigger-daily-reminders', methods=['POST'])
|
||||
@jwt_required()
|
||||
def trigger_daily_reminders():
|
||||
"""手動觸發每日提醒(管理員功能)"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
# TODO: 實作管理員權限檢查
|
||||
# 這裡應該檢查用戶是否為管理員
|
||||
|
||||
# 直接執行任務
|
||||
result = send_daily_reminders()
|
||||
|
||||
# 記錄稽核日誌
|
||||
audit = TodoAuditLog(
|
||||
actor_ad=identity,
|
||||
todo_id=None,
|
||||
action='MANUAL_REMINDER',
|
||||
detail={
|
||||
'result': result,
|
||||
'triggered_by': identity
|
||||
}
|
||||
)
|
||||
db.session.add(audit)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Daily reminders executed manually by {identity}")
|
||||
|
||||
return jsonify({
|
||||
'message': '每日提醒任務已執行',
|
||||
'result': result
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error triggering daily reminders: {str(e)}")
|
||||
return jsonify({'error': '觸發每日提醒失敗'}), 500
|
||||
|
||||
@scheduler_bp.route('/trigger-weekly-summary', methods=['POST'])
|
||||
@jwt_required()
|
||||
def trigger_weekly_summary():
|
||||
"""手動觸發週報發送(管理員功能)"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
# TODO: 實作管理員權限檢查
|
||||
|
||||
# 直接執行任務
|
||||
result = send_weekly_summary()
|
||||
|
||||
# 記錄稽核日誌
|
||||
audit = TodoAuditLog(
|
||||
actor_ad=identity,
|
||||
todo_id=None,
|
||||
action='MANUAL_SUMMARY',
|
||||
detail={
|
||||
'result': result,
|
||||
'triggered_by': identity
|
||||
}
|
||||
)
|
||||
db.session.add(audit)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Weekly summary executed manually by {identity}")
|
||||
|
||||
return jsonify({
|
||||
'message': '週報發送任務已執行',
|
||||
'result': result
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error triggering weekly summary: {str(e)}")
|
||||
return jsonify({'error': '觸發週報發送失敗'}), 500
|
||||
|
||||
@scheduler_bp.route('/task-status/<task_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_task_status(task_id):
|
||||
"""取得任務狀態(簡化版本)"""
|
||||
try:
|
||||
# 在簡化版本中,任務是同步執行的,所以狀態總是 completed
|
||||
return jsonify({
|
||||
'task_id': task_id,
|
||||
'status': 'completed',
|
||||
'message': '任務已同步執行完成'
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting task status: {str(e)}")
|
||||
return jsonify({'error': '取得任務狀態失敗'}), 500
|
||||
|
||||
@scheduler_bp.route('/scheduled-jobs', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_scheduled_jobs():
|
||||
"""取得排程任務列表和狀態"""
|
||||
try:
|
||||
# 這裡可以返回 Celery Beat 的排程資訊
|
||||
# 簡化版本,返回配置的排程任務
|
||||
jobs = [
|
||||
{
|
||||
'name': 'daily-reminders',
|
||||
'description': '每日提醒郵件',
|
||||
'schedule': '每日早上9點',
|
||||
'status': 'active',
|
||||
'last_run': None # TODO: 從 Celery 取得實際執行時間
|
||||
},
|
||||
{
|
||||
'name': 'weekly-summary',
|
||||
'description': '每週摘要報告',
|
||||
'schedule': '每週一早上9點',
|
||||
'status': 'active',
|
||||
'last_run': None # TODO: 從 Celery 取得實際執行時間
|
||||
},
|
||||
{
|
||||
'name': 'cleanup-logs',
|
||||
'description': '清理舊日誌',
|
||||
'schedule': '每週執行一次',
|
||||
'status': 'active',
|
||||
'last_run': None # TODO: 從 Celery 取得實際執行時間
|
||||
}
|
||||
]
|
||||
|
||||
return jsonify({'jobs': jobs}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting scheduled jobs: {str(e)}")
|
||||
return jsonify({'error': '取得排程任務列表失敗'}), 500
|
||||
|
||||
@scheduler_bp.route('/statistics', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_scheduler_statistics():
|
||||
"""取得排程系統統計資訊"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
# 統計最近一週的自動化任務執行記錄
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
|
||||
auto_tasks = TodoAuditLog.query.filter(
|
||||
and_(
|
||||
TodoAuditLog.actor_ad == 'system',
|
||||
TodoAuditLog.created_at >= week_ago,
|
||||
TodoAuditLog.action.in_(['DAILY_REMINDER', 'WEEKLY_SUMMARY'])
|
||||
)
|
||||
).all()
|
||||
|
||||
# 統計手動觸發的任務
|
||||
manual_tasks = TodoAuditLog.query.filter(
|
||||
and_(
|
||||
TodoAuditLog.created_at >= week_ago,
|
||||
TodoAuditLog.action.in_(['MANUAL_REMINDER', 'MANUAL_SUMMARY'])
|
||||
)
|
||||
).all()
|
||||
|
||||
# 統計郵件發送情況
|
||||
email_stats = {}
|
||||
for task in auto_tasks:
|
||||
if task.detail:
|
||||
task_type = task.action.lower()
|
||||
if 'emails_sent' in task.detail:
|
||||
if task_type not in email_stats:
|
||||
email_stats[task_type] = {'count': 0, 'emails': 0}
|
||||
email_stats[task_type]['count'] += 1
|
||||
email_stats[task_type]['emails'] += task.detail['emails_sent']
|
||||
|
||||
statistics = {
|
||||
'recent_activity': {
|
||||
'auto_tasks_count': len(auto_tasks),
|
||||
'manual_tasks_count': len(manual_tasks),
|
||||
'email_stats': email_stats
|
||||
},
|
||||
'system_health': {
|
||||
'celery_status': 'running', # TODO: 實際檢查 Celery 狀態
|
||||
'redis_status': 'connected', # TODO: 實際檢查 Redis 狀態
|
||||
'last_daily_reminder': None, # TODO: 從記錄中取得
|
||||
'last_weekly_summary': None # TODO: 從記錄中取得
|
||||
}
|
||||
}
|
||||
|
||||
return jsonify(statistics), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting scheduler statistics: {str(e)}")
|
||||
return jsonify({'error': '取得排程統計資訊失敗'}), 500
|
||||
|
||||
@scheduler_bp.route('/preview-reminders', methods=['GET'])
|
||||
@jwt_required()
|
||||
def preview_reminders():
|
||||
"""預覽即將發送的提醒郵件"""
|
||||
try:
|
||||
today = date.today()
|
||||
tomorrow = today + timedelta(days=1)
|
||||
|
||||
# 查找明日到期的待辦事項
|
||||
due_tomorrow = db.session.query(TodoItem).filter(
|
||||
and_(
|
||||
TodoItem.due_date == tomorrow,
|
||||
TodoItem.status != 'DONE'
|
||||
)
|
||||
).all()
|
||||
|
||||
# 查找已逾期的待辦事項
|
||||
overdue = db.session.query(TodoItem).filter(
|
||||
and_(
|
||||
TodoItem.due_date < today,
|
||||
TodoItem.status != 'DONE'
|
||||
)
|
||||
).all()
|
||||
|
||||
# 統計會收到提醒的使用者
|
||||
notification_service = NotificationService()
|
||||
due_tomorrow_recipients = set()
|
||||
overdue_recipients = set()
|
||||
|
||||
for todo in due_tomorrow:
|
||||
recipients = notification_service.get_notification_recipients(todo)
|
||||
due_tomorrow_recipients.update(recipients)
|
||||
|
||||
for todo in overdue:
|
||||
recipients = notification_service.get_notification_recipients(todo)
|
||||
overdue_recipients.update(recipients)
|
||||
|
||||
preview = {
|
||||
'due_tomorrow': {
|
||||
'todos_count': len(due_tomorrow),
|
||||
'recipients_count': len(due_tomorrow_recipients),
|
||||
'todos': [todo.to_dict() for todo in due_tomorrow[:5]] # 只顯示前5個
|
||||
},
|
||||
'overdue': {
|
||||
'todos_count': len(overdue),
|
||||
'recipients_count': len(overdue_recipients),
|
||||
'todos': [todo.to_dict() for todo in overdue[:5]] # 只顯示前5個
|
||||
},
|
||||
'total_emails': len(due_tomorrow_recipients) + len(overdue_recipients)
|
||||
}
|
||||
|
||||
return jsonify(preview), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error previewing reminders: {str(e)}")
|
||||
return jsonify({'error': '預覽提醒郵件失敗'}), 500
|
709
backend/routes/todos.py
Normal file
709
backend/routes/todos.py
Normal file
@@ -0,0 +1,709 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
|
||||
from datetime import datetime, date, timedelta
|
||||
from sqlalchemy import or_, and_
|
||||
from sqlalchemy.orm import selectinload, joinedload
|
||||
from models import (
|
||||
db, TodoItem, TodoItemResponsible, TodoItemFollower,
|
||||
TodoAuditLog, TodoUserPref
|
||||
)
|
||||
from utils.logger import get_logger
|
||||
from utils.ldap_utils import validate_ad_accounts
|
||||
import uuid
|
||||
|
||||
todos_bp = Blueprint('todos', __name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@todos_bp.route('', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_todos():
|
||||
"""Get todos with filtering and pagination"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 20, type=int)
|
||||
|
||||
# Filters
|
||||
status = request.args.get('status')
|
||||
priority = request.args.get('priority')
|
||||
starred = request.args.get('starred', type=bool)
|
||||
due_from = request.args.get('due_from')
|
||||
due_to = request.args.get('due_to')
|
||||
search = request.args.get('search')
|
||||
view_type = request.args.get('view', 'all') # all, created, responsible, following
|
||||
|
||||
# Base query with eager loading to prevent N+1 queries
|
||||
query = TodoItem.query.options(
|
||||
joinedload(TodoItem.responsible_users),
|
||||
joinedload(TodoItem.followers)
|
||||
)
|
||||
|
||||
# Apply view type filter
|
||||
if view_type == 'created':
|
||||
query = query.filter(TodoItem.creator_ad == identity)
|
||||
elif view_type == 'responsible':
|
||||
query = query.join(TodoItemResponsible).filter(
|
||||
TodoItemResponsible.ad_account == identity
|
||||
)
|
||||
elif view_type == 'following':
|
||||
query = query.join(TodoItemFollower).filter(
|
||||
TodoItemFollower.ad_account == identity
|
||||
)
|
||||
else: # all
|
||||
query = query.filter(
|
||||
or_(
|
||||
TodoItem.creator_ad == identity,
|
||||
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity),
|
||||
TodoItem.followers.any(TodoItemFollower.ad_account == identity)
|
||||
)
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if status:
|
||||
query = query.filter(TodoItem.status == status)
|
||||
if priority:
|
||||
query = query.filter(TodoItem.priority == priority)
|
||||
if starred is not None:
|
||||
query = query.filter(TodoItem.starred == starred)
|
||||
if due_from:
|
||||
query = query.filter(TodoItem.due_date >= datetime.strptime(due_from, '%Y-%m-%d').date())
|
||||
if due_to:
|
||||
query = query.filter(TodoItem.due_date <= datetime.strptime(due_to, '%Y-%m-%d').date())
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
TodoItem.title.contains(search),
|
||||
TodoItem.description.contains(search)
|
||||
)
|
||||
)
|
||||
|
||||
# Order by due date and priority (MySQL compatible)
|
||||
query = query.order_by(
|
||||
TodoItem.due_date.asc(),
|
||||
TodoItem.priority.desc(),
|
||||
TodoItem.created_at.desc()
|
||||
)
|
||||
|
||||
# Paginate
|
||||
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||
|
||||
todos = [todo.to_dict() for todo in pagination.items]
|
||||
|
||||
return jsonify({
|
||||
'todos': todos,
|
||||
'total': pagination.total,
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'pages': pagination.pages
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching todos: {str(e)}")
|
||||
return jsonify({'error': 'Failed to fetch todos'}), 500
|
||||
|
||||
@todos_bp.route('/<todo_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_todo(todo_id):
|
||||
"""Get single todo details"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
todo = TodoItem.query.options(
|
||||
joinedload(TodoItem.responsible_users),
|
||||
joinedload(TodoItem.followers)
|
||||
).filter_by(id=todo_id).first()
|
||||
if not todo:
|
||||
return jsonify({'error': 'Todo not found'}), 404
|
||||
|
||||
# Check permission
|
||||
if not todo.can_view(identity):
|
||||
return jsonify({'error': 'Access denied'}), 403
|
||||
|
||||
return jsonify(todo.to_dict()), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching todo {todo_id}: {str(e)}")
|
||||
return jsonify({'error': 'Failed to fetch todo'}), 500
|
||||
|
||||
@todos_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_todo():
|
||||
"""Create new todo"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
claims = get_jwt()
|
||||
data = request.get_json()
|
||||
|
||||
# Validate required fields
|
||||
if not data.get('title'):
|
||||
return jsonify({'error': 'Title is required'}), 400
|
||||
|
||||
# Parse due date if provided
|
||||
due_date = None
|
||||
if data.get('due_date'):
|
||||
try:
|
||||
due_date = datetime.strptime(data['due_date'], '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid due date format. Use YYYY-MM-DD'}), 400
|
||||
|
||||
# Create todo
|
||||
todo = TodoItem(
|
||||
id=str(uuid.uuid4()),
|
||||
title=data['title'],
|
||||
description=data.get('description', ''),
|
||||
status=data.get('status', 'NEW'),
|
||||
priority=data.get('priority', 'MEDIUM'),
|
||||
due_date=due_date,
|
||||
creator_ad=identity,
|
||||
creator_display_name=claims.get('display_name', identity),
|
||||
creator_email=claims.get('email', ''),
|
||||
starred=data.get('starred', False)
|
||||
)
|
||||
db.session.add(todo)
|
||||
|
||||
# Add responsible users
|
||||
responsible_accounts = data.get('responsible_users', [])
|
||||
if responsible_accounts:
|
||||
valid_accounts = validate_ad_accounts(responsible_accounts)
|
||||
for account in responsible_accounts:
|
||||
if account in valid_accounts:
|
||||
responsible = TodoItemResponsible(
|
||||
todo_id=todo.id,
|
||||
ad_account=account,
|
||||
added_by=identity
|
||||
)
|
||||
db.session.add(responsible)
|
||||
|
||||
# Add followers
|
||||
follower_accounts = data.get('followers', [])
|
||||
if follower_accounts:
|
||||
valid_accounts = validate_ad_accounts(follower_accounts)
|
||||
for account in follower_accounts:
|
||||
if account in valid_accounts:
|
||||
follower = TodoItemFollower(
|
||||
todo_id=todo.id,
|
||||
ad_account=account,
|
||||
added_by=identity
|
||||
)
|
||||
db.session.add(follower)
|
||||
|
||||
# Add audit log
|
||||
audit = TodoAuditLog(
|
||||
actor_ad=identity,
|
||||
todo_id=todo.id,
|
||||
action='CREATE',
|
||||
detail={'title': todo.title, 'due_date': str(due_date) if due_date else None}
|
||||
)
|
||||
db.session.add(audit)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Todo created: {todo.id} by {identity}")
|
||||
return jsonify(todo.to_dict()), 201
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Error creating todo: {str(e)}")
|
||||
return jsonify({'error': 'Failed to create todo'}), 500
|
||||
|
||||
@todos_bp.route('/<todo_id>', methods=['PATCH'])
|
||||
@jwt_required()
|
||||
def update_todo(todo_id):
|
||||
"""Update todo"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
data = request.get_json()
|
||||
|
||||
todo = TodoItem.query.filter_by(id=todo_id).first()
|
||||
if not todo:
|
||||
return jsonify({'error': 'Todo not found'}), 404
|
||||
|
||||
# Check permission
|
||||
if not todo.can_edit(identity):
|
||||
return jsonify({'error': 'Access denied'}), 403
|
||||
|
||||
# Track changes for audit
|
||||
changes = {}
|
||||
|
||||
# Update fields
|
||||
if 'title' in data:
|
||||
changes['title'] = {'old': todo.title, 'new': data['title']}
|
||||
todo.title = data['title']
|
||||
|
||||
if 'description' in data:
|
||||
changes['description'] = {'old': todo.description, 'new': data['description']}
|
||||
todo.description = data['description']
|
||||
|
||||
if 'status' in data:
|
||||
changes['status'] = {'old': todo.status, 'new': data['status']}
|
||||
todo.status = data['status']
|
||||
|
||||
# Set completed_at if status is DONE
|
||||
if data['status'] == 'DONE' and not todo.completed_at:
|
||||
todo.completed_at = datetime.utcnow()
|
||||
elif data['status'] != 'DONE':
|
||||
todo.completed_at = None
|
||||
|
||||
if 'priority' in data:
|
||||
changes['priority'] = {'old': todo.priority, 'new': data['priority']}
|
||||
todo.priority = data['priority']
|
||||
|
||||
if 'due_date' in data:
|
||||
old_due = str(todo.due_date) if todo.due_date else None
|
||||
if data['due_date']:
|
||||
todo.due_date = datetime.strptime(data['due_date'], '%Y-%m-%d').date()
|
||||
new_due = data['due_date']
|
||||
else:
|
||||
todo.due_date = None
|
||||
new_due = None
|
||||
changes['due_date'] = {'old': old_due, 'new': new_due}
|
||||
|
||||
if 'starred' in data:
|
||||
changes['starred'] = {'old': todo.starred, 'new': data['starred']}
|
||||
todo.starred = data['starred']
|
||||
|
||||
# Update responsible users
|
||||
if 'responsible_users' in data:
|
||||
# Remove existing
|
||||
TodoItemResponsible.query.filter_by(todo_id=todo_id).delete()
|
||||
|
||||
# Add new
|
||||
responsible_accounts = data['responsible_users']
|
||||
if responsible_accounts:
|
||||
valid_accounts = validate_ad_accounts(responsible_accounts)
|
||||
for account in responsible_accounts:
|
||||
if account in valid_accounts:
|
||||
responsible = TodoItemResponsible(
|
||||
todo_id=todo.id,
|
||||
ad_account=account,
|
||||
added_by=identity
|
||||
)
|
||||
db.session.add(responsible)
|
||||
|
||||
changes['responsible_users'] = data['responsible_users']
|
||||
|
||||
# Update followers
|
||||
if 'followers' in data:
|
||||
# Remove existing
|
||||
TodoItemFollower.query.filter_by(todo_id=todo_id).delete()
|
||||
|
||||
# Add new
|
||||
follower_accounts = data['followers']
|
||||
if follower_accounts:
|
||||
valid_accounts = validate_ad_accounts(follower_accounts)
|
||||
for account in follower_accounts:
|
||||
if account in valid_accounts:
|
||||
follower = TodoItemFollower(
|
||||
todo_id=todo.id,
|
||||
ad_account=account,
|
||||
added_by=identity
|
||||
)
|
||||
db.session.add(follower)
|
||||
|
||||
changes['followers'] = data['followers']
|
||||
|
||||
# Add audit log
|
||||
if changes:
|
||||
audit = TodoAuditLog(
|
||||
actor_ad=identity,
|
||||
todo_id=todo.id,
|
||||
action='UPDATE',
|
||||
detail=changes
|
||||
)
|
||||
db.session.add(audit)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Todo updated: {todo_id} by {identity}")
|
||||
return jsonify(todo.to_dict()), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Error updating todo {todo_id}: {str(e)}")
|
||||
return jsonify({'error': 'Failed to update todo'}), 500
|
||||
|
||||
@todos_bp.route('/<todo_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
def delete_todo(todo_id):
|
||||
"""Delete todo"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
todo = TodoItem.query.filter_by(id=todo_id).first()
|
||||
if not todo:
|
||||
return jsonify({'error': 'Todo not found'}), 404
|
||||
|
||||
# Only creator can delete
|
||||
if todo.creator_ad != identity:
|
||||
return jsonify({'error': 'Only creator can delete todo'}), 403
|
||||
|
||||
# Add audit log before deletion
|
||||
audit = TodoAuditLog(
|
||||
actor_ad=identity,
|
||||
todo_id=None, # Will be null after deletion
|
||||
action='DELETE',
|
||||
detail={'title': todo.title, 'deleted_todo_id': todo_id}
|
||||
)
|
||||
db.session.add(audit)
|
||||
|
||||
# Delete todo (cascades will handle related records)
|
||||
db.session.delete(todo)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Todo deleted: {todo_id} by {identity}")
|
||||
return jsonify({'message': 'Todo deleted successfully'}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Error deleting todo {todo_id}: {str(e)}")
|
||||
return jsonify({'error': 'Failed to delete todo'}), 500
|
||||
|
||||
@todos_bp.route('/batch', methods=['PATCH'])
|
||||
@jwt_required()
|
||||
def batch_update_todos():
|
||||
"""Batch update multiple todos"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
data = request.get_json()
|
||||
|
||||
todo_ids = data.get('todo_ids', [])
|
||||
updates = data.get('updates', {})
|
||||
|
||||
if not todo_ids or not updates:
|
||||
return jsonify({'error': 'Todo IDs and updates required'}), 400
|
||||
|
||||
updated_count = 0
|
||||
errors = []
|
||||
|
||||
for todo_id in todo_ids:
|
||||
try:
|
||||
todo = TodoItem.query.filter_by(id=todo_id).first()
|
||||
if not todo:
|
||||
errors.append({'todo_id': todo_id, 'error': 'Not found'})
|
||||
continue
|
||||
|
||||
if not todo.can_edit(identity):
|
||||
errors.append({'todo_id': todo_id, 'error': 'Access denied'})
|
||||
continue
|
||||
|
||||
# Apply updates
|
||||
if 'status' in updates:
|
||||
todo.status = updates['status']
|
||||
if updates['status'] == 'DONE':
|
||||
todo.completed_at = datetime.utcnow()
|
||||
else:
|
||||
todo.completed_at = None
|
||||
|
||||
if 'priority' in updates:
|
||||
todo.priority = updates['priority']
|
||||
|
||||
if 'due_date' in updates:
|
||||
if updates['due_date']:
|
||||
todo.due_date = datetime.strptime(updates['due_date'], '%Y-%m-%d').date()
|
||||
else:
|
||||
todo.due_date = None
|
||||
|
||||
# Add audit log
|
||||
audit = TodoAuditLog(
|
||||
actor_ad=identity,
|
||||
todo_id=todo.id,
|
||||
action='UPDATE',
|
||||
detail={'batch_update': updates}
|
||||
)
|
||||
db.session.add(audit)
|
||||
|
||||
updated_count += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append({'todo_id': todo_id, 'error': str(e)})
|
||||
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Batch update: {updated_count} todos updated by {identity}")
|
||||
|
||||
return jsonify({
|
||||
'updated': updated_count,
|
||||
'errors': errors
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Error in batch update: {str(e)}")
|
||||
return jsonify({'error': 'Batch update failed'}), 500
|
||||
|
||||
@todos_bp.route('/<todo_id>/responsible', methods=['POST'])
|
||||
@jwt_required()
|
||||
def add_responsible_user(todo_id):
|
||||
"""Add responsible user to todo"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'ad_account' not in data:
|
||||
return jsonify({'error': 'AD account is required'}), 400
|
||||
|
||||
ad_account = data['ad_account']
|
||||
|
||||
# Get todo
|
||||
todo = TodoItem.query.filter_by(id=todo_id).first()
|
||||
if not todo:
|
||||
return jsonify({'error': 'Todo not found'}), 404
|
||||
|
||||
# Check permission
|
||||
if not todo.can_edit(identity):
|
||||
return jsonify({'error': 'No permission to edit this todo'}), 403
|
||||
|
||||
# Validate AD account
|
||||
valid_accounts = validate_ad_accounts([ad_account])
|
||||
if ad_account not in valid_accounts:
|
||||
return jsonify({'error': 'Invalid AD account'}), 400
|
||||
|
||||
# Check if already responsible
|
||||
existing = TodoItemResponsible.query.filter_by(
|
||||
todo_id=todo_id, ad_account=ad_account
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return jsonify({'error': 'User is already responsible for this todo'}), 400
|
||||
|
||||
# Add responsible user
|
||||
responsible = TodoItemResponsible(
|
||||
todo_id=todo_id,
|
||||
ad_account=ad_account,
|
||||
added_by=identity
|
||||
)
|
||||
db.session.add(responsible)
|
||||
|
||||
# Log audit
|
||||
audit = TodoAuditLog(
|
||||
actor_ad=identity,
|
||||
todo_id=todo_id,
|
||||
action='UPDATE',
|
||||
detail={
|
||||
'field': 'responsible_users',
|
||||
'action': 'add',
|
||||
'ad_account': ad_account
|
||||
}
|
||||
)
|
||||
db.session.add(audit)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Added responsible user {ad_account} to todo {todo_id} by {identity}")
|
||||
return jsonify({'message': 'Responsible user added successfully'}), 201
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Add responsible user error: {str(e)}")
|
||||
return jsonify({'error': 'Failed to add responsible user'}), 500
|
||||
|
||||
@todos_bp.route('/<todo_id>/responsible/<ad_account>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
def remove_responsible_user(todo_id, ad_account):
|
||||
"""Remove responsible user from todo"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
# Get todo
|
||||
todo = TodoItem.query.filter_by(id=todo_id).first()
|
||||
if not todo:
|
||||
return jsonify({'error': 'Todo not found'}), 404
|
||||
|
||||
# Check permission
|
||||
if not todo.can_edit(identity):
|
||||
return jsonify({'error': 'No permission to edit this todo'}), 403
|
||||
|
||||
# Find responsible relationship
|
||||
responsible = TodoItemResponsible.query.filter_by(
|
||||
todo_id=todo_id, ad_account=ad_account
|
||||
).first()
|
||||
|
||||
if not responsible:
|
||||
return jsonify({'error': 'User is not responsible for this todo'}), 404
|
||||
|
||||
# Remove responsible user
|
||||
db.session.delete(responsible)
|
||||
|
||||
# Log audit
|
||||
audit = TodoAuditLog(
|
||||
actor_ad=identity,
|
||||
todo_id=todo_id,
|
||||
action='UPDATE',
|
||||
detail={
|
||||
'field': 'responsible_users',
|
||||
'action': 'remove',
|
||||
'ad_account': ad_account
|
||||
}
|
||||
)
|
||||
db.session.add(audit)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Removed responsible user {ad_account} from todo {todo_id} by {identity}")
|
||||
return jsonify({'message': 'Responsible user removed successfully'}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Remove responsible user error: {str(e)}")
|
||||
return jsonify({'error': 'Failed to remove responsible user'}), 500
|
||||
|
||||
@todos_bp.route('/<todo_id>/followers', methods=['POST'])
|
||||
@jwt_required()
|
||||
def add_follower(todo_id):
|
||||
"""Add follower to todo"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'ad_account' not in data:
|
||||
return jsonify({'error': 'AD account is required'}), 400
|
||||
|
||||
ad_account = data['ad_account']
|
||||
|
||||
# Get todo
|
||||
todo = TodoItem.query.filter_by(id=todo_id).first()
|
||||
if not todo:
|
||||
return jsonify({'error': 'Todo not found'}), 404
|
||||
|
||||
# Check permission (anyone who can view the todo can add followers)
|
||||
if not todo.can_view(identity):
|
||||
return jsonify({'error': 'No permission to view this todo'}), 403
|
||||
|
||||
# Validate AD account
|
||||
valid_accounts = validate_ad_accounts([ad_account])
|
||||
if ad_account not in valid_accounts:
|
||||
return jsonify({'error': 'Invalid AD account'}), 400
|
||||
|
||||
# Check if already following
|
||||
existing = TodoItemFollower.query.filter_by(
|
||||
todo_id=todo_id, ad_account=ad_account
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return jsonify({'error': 'User is already following this todo'}), 400
|
||||
|
||||
# Add follower
|
||||
follower = TodoItemFollower(
|
||||
todo_id=todo_id,
|
||||
ad_account=ad_account,
|
||||
added_by=identity
|
||||
)
|
||||
db.session.add(follower)
|
||||
|
||||
# Log audit
|
||||
audit = TodoAuditLog(
|
||||
actor_ad=identity,
|
||||
todo_id=todo_id,
|
||||
action='UPDATE',
|
||||
detail={
|
||||
'field': 'followers',
|
||||
'action': 'add',
|
||||
'ad_account': ad_account
|
||||
}
|
||||
)
|
||||
db.session.add(audit)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Added follower {ad_account} to todo {todo_id} by {identity}")
|
||||
return jsonify({'message': 'Follower added successfully'}), 201
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Add follower error: {str(e)}")
|
||||
return jsonify({'error': 'Failed to add follower'}), 500
|
||||
|
||||
@todos_bp.route('/<todo_id>/followers/<ad_account>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
def remove_follower(todo_id, ad_account):
|
||||
"""Remove follower from todo"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
# Get todo
|
||||
todo = TodoItem.query.filter_by(id=todo_id).first()
|
||||
if not todo:
|
||||
return jsonify({'error': 'Todo not found'}), 404
|
||||
|
||||
# Check permission (user can remove themselves or todo editors can remove anyone)
|
||||
if ad_account != identity and not todo.can_edit(identity):
|
||||
return jsonify({'error': 'No permission to remove this follower'}), 403
|
||||
|
||||
# Find follower relationship
|
||||
follower = TodoItemFollower.query.filter_by(
|
||||
todo_id=todo_id, ad_account=ad_account
|
||||
).first()
|
||||
|
||||
if not follower:
|
||||
return jsonify({'error': 'User is not following this todo'}), 404
|
||||
|
||||
# Remove follower
|
||||
db.session.delete(follower)
|
||||
|
||||
# Log audit
|
||||
audit = TodoAuditLog(
|
||||
actor_ad=identity,
|
||||
todo_id=todo_id,
|
||||
action='UPDATE',
|
||||
detail={
|
||||
'field': 'followers',
|
||||
'action': 'remove',
|
||||
'ad_account': ad_account
|
||||
}
|
||||
)
|
||||
db.session.add(audit)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Removed follower {ad_account} from todo {todo_id} by {identity}")
|
||||
return jsonify({'message': 'Follower removed successfully'}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Remove follower error: {str(e)}")
|
||||
return jsonify({'error': 'Failed to remove follower'}), 500
|
||||
|
||||
@todos_bp.route('/<todo_id>/star', methods=['POST'])
|
||||
@jwt_required()
|
||||
def star_todo(todo_id):
|
||||
"""Star/unstar a todo item"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
# Get todo
|
||||
todo = TodoItem.query.filter_by(id=todo_id).first()
|
||||
if not todo:
|
||||
return jsonify({'error': 'Todo not found'}), 404
|
||||
|
||||
# Check permission
|
||||
if not todo.can_view(identity):
|
||||
return jsonify({'error': 'No permission to view this todo'}), 403
|
||||
|
||||
# Only creator can star/unstar
|
||||
if todo.creator_ad != identity:
|
||||
return jsonify({'error': 'Only creator can star/unstar todos'}), 403
|
||||
|
||||
# Toggle star status
|
||||
todo.starred = not todo.starred
|
||||
|
||||
# Log audit
|
||||
audit = TodoAuditLog(
|
||||
actor_ad=identity,
|
||||
todo_id=todo_id,
|
||||
action='UPDATE',
|
||||
detail={
|
||||
'field': 'starred',
|
||||
'value': todo.starred
|
||||
}
|
||||
)
|
||||
db.session.add(audit)
|
||||
db.session.commit()
|
||||
|
||||
action = 'starred' if todo.starred else 'unstarred'
|
||||
logger.info(f"Todo {todo_id} {action} by {identity}")
|
||||
|
||||
return jsonify({
|
||||
'message': f'Todo {action} successfully',
|
||||
'starred': todo.starred
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Star todo error: {str(e)}")
|
||||
return jsonify({'error': 'Failed to star todo'}), 500
|
128
backend/routes/users.py
Normal file
128
backend/routes/users.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
from datetime import datetime, date
|
||||
from models import db, TodoUserPref
|
||||
from utils.logger import get_logger
|
||||
|
||||
users_bp = Blueprint('users', __name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@users_bp.route('/search', methods=['GET'])
|
||||
@jwt_required()
|
||||
def search_users():
|
||||
"""Search for AD users"""
|
||||
try:
|
||||
search_term = request.args.get('q', '').strip()
|
||||
|
||||
if len(search_term) < 1:
|
||||
return jsonify({'error': 'Search term cannot be empty'}), 400
|
||||
|
||||
# Search LDAP (or mock for development)
|
||||
try:
|
||||
if current_app.config.get('USE_MOCK_LDAP', False):
|
||||
from utils.mock_ldap import search_ldap_principals
|
||||
else:
|
||||
from utils.ldap_utils import search_ldap_principals
|
||||
|
||||
results = search_ldap_principals(search_term, limit=20)
|
||||
except Exception as e:
|
||||
logger.error(f"LDAP search error, falling back to mock: {str(e)}")
|
||||
from utils.mock_ldap import search_ldap_principals
|
||||
results = search_ldap_principals(search_term, limit=20)
|
||||
|
||||
return jsonify({'users': results}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"User search error: {str(e)}")
|
||||
return jsonify({'error': 'Search failed'}), 500
|
||||
|
||||
@users_bp.route('/preferences', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_preferences():
|
||||
"""Get user preferences"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
|
||||
if not user_pref:
|
||||
return jsonify({'error': 'User preferences not found'}), 404
|
||||
|
||||
return jsonify(user_pref.to_dict()), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching preferences: {str(e)}")
|
||||
return jsonify({'error': 'Failed to fetch preferences'}), 500
|
||||
|
||||
@users_bp.route('/preferences', methods=['PATCH'])
|
||||
@jwt_required()
|
||||
def update_preferences():
|
||||
"""Update user preferences"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
data = request.get_json()
|
||||
|
||||
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
|
||||
if not user_pref:
|
||||
return jsonify({'error': 'User preferences not found'}), 404
|
||||
|
||||
# Update allowed fields
|
||||
if 'theme' in data and data['theme'] in ['light', 'dark', 'auto']:
|
||||
user_pref.theme = data['theme']
|
||||
|
||||
if 'language' in data:
|
||||
user_pref.language = data['language']
|
||||
|
||||
if 'timezone' in data:
|
||||
user_pref.timezone = data['timezone']
|
||||
|
||||
if 'notification_enabled' in data:
|
||||
user_pref.notification_enabled = bool(data['notification_enabled'])
|
||||
|
||||
if 'email_reminder_enabled' in data:
|
||||
user_pref.email_reminder_enabled = bool(data['email_reminder_enabled'])
|
||||
|
||||
if 'weekly_summary_enabled' in data:
|
||||
user_pref.weekly_summary_enabled = bool(data['weekly_summary_enabled'])
|
||||
|
||||
|
||||
user_pref.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Preferences updated for user: {identity}")
|
||||
return jsonify(user_pref.to_dict()), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Error updating preferences: {str(e)}")
|
||||
return jsonify({'error': 'Failed to update preferences'}), 500
|
||||
|
||||
@users_bp.route('/fire-email-quota', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_fire_email_quota():
|
||||
"""Get user's fire email quota for today"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
|
||||
if not user_pref:
|
||||
return jsonify({'error': 'User not found'}), 404
|
||||
|
||||
# Reset counter if it's a new day
|
||||
today = date.today()
|
||||
if user_pref.fire_email_last_reset != today:
|
||||
user_pref.fire_email_today_count = 0
|
||||
user_pref.fire_email_last_reset = today
|
||||
db.session.commit()
|
||||
|
||||
from flask import current_app
|
||||
daily_limit = current_app.config['FIRE_EMAIL_DAILY_LIMIT']
|
||||
|
||||
return jsonify({
|
||||
'used': user_pref.fire_email_today_count,
|
||||
'limit': daily_limit,
|
||||
'remaining': max(0, daily_limit - user_pref.fire_email_today_count)
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching fire email quota: {str(e)}")
|
||||
return jsonify({'error': 'Failed to fetch quota'}), 500
|
226
backend/tasks.py
Normal file
226
backend/tasks.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Celery Tasks for Background Jobs
|
||||
處理排程任務,包括提醒郵件和摘要報告
|
||||
"""
|
||||
|
||||
from celery import Celery
|
||||
from datetime import datetime, date, timedelta
|
||||
from sqlalchemy import and_, or_
|
||||
from models import (
|
||||
db, TodoItem, TodoItemResponsible, TodoItemFollower,
|
||||
TodoUserPref, TodoAuditLog
|
||||
)
|
||||
from utils.email_service import EmailService
|
||||
from utils.notification_service import NotificationService
|
||||
from utils.logger import get_logger
|
||||
import os
|
||||
|
||||
# 建立 Celery 實例
|
||||
def make_celery(app):
|
||||
celery = Celery(
|
||||
app.import_name,
|
||||
backend=app.config['CELERY_RESULT_BACKEND'],
|
||||
broker=app.config['CELERY_BROKER_URL']
|
||||
)
|
||||
celery.conf.update(app.config)
|
||||
|
||||
class ContextTask(celery.Task):
|
||||
"""Make celery tasks work with Flask app context"""
|
||||
def __call__(self, *args, **kwargs):
|
||||
with app.app_context():
|
||||
return self.run(*args, **kwargs)
|
||||
|
||||
celery.Task = ContextTask
|
||||
return celery
|
||||
|
||||
# 建立 Flask 應用程式和 Celery
|
||||
def create_celery_app():
|
||||
"""建立 Celery 應用程式,延遲導入避免循環依賴"""
|
||||
from app import create_app
|
||||
flask_app = create_app()
|
||||
return make_celery(flask_app), flask_app
|
||||
|
||||
# 全局變數,延遲初始化
|
||||
celery = None
|
||||
flask_app = None
|
||||
|
||||
def get_celery():
|
||||
"""獲取 Celery 實例"""
|
||||
global celery, flask_app
|
||||
if celery is None:
|
||||
celery, flask_app = create_celery_app()
|
||||
return celery
|
||||
logger = get_logger(__name__)
|
||||
|
||||
def send_daily_reminders():
|
||||
"""發送每日提醒郵件"""
|
||||
try:
|
||||
celery_app = get_celery()
|
||||
from app import create_app
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
today = date.today()
|
||||
tomorrow = today + timedelta(days=1)
|
||||
|
||||
# 查找明日到期的待辦事項
|
||||
due_tomorrow = db.session.query(TodoItem).filter(
|
||||
and_(
|
||||
TodoItem.due_date == tomorrow,
|
||||
TodoItem.status != 'DONE'
|
||||
)
|
||||
).all()
|
||||
|
||||
# 查找已逾期的待辦事項
|
||||
overdue = db.session.query(TodoItem).filter(
|
||||
and_(
|
||||
TodoItem.due_date < today,
|
||||
TodoItem.status != 'DONE'
|
||||
)
|
||||
).all()
|
||||
|
||||
email_service = EmailService()
|
||||
notification_service = NotificationService()
|
||||
sent_count = 0
|
||||
|
||||
# 處理明日到期提醒
|
||||
for todo in due_tomorrow:
|
||||
recipients = notification_service.get_notification_recipients(todo)
|
||||
for recipient in recipients:
|
||||
try:
|
||||
# 檢查用戶是否啟用郵件提醒
|
||||
user_pref = TodoUserPref.query.filter_by(ad_account=recipient).first()
|
||||
if not user_pref or not user_pref.email_reminder_enabled:
|
||||
continue
|
||||
|
||||
if email_service.send_reminder_email(todo, recipient, 'due_tomorrow'):
|
||||
sent_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send due tomorrow reminder to {recipient}: {str(e)}")
|
||||
|
||||
# 處理逾期提醒
|
||||
for todo in overdue:
|
||||
recipients = notification_service.get_notification_recipients(todo)
|
||||
for recipient in recipients:
|
||||
try:
|
||||
# 檢查用戶是否啟用郵件提醒
|
||||
user_pref = TodoUserPref.query.filter_by(ad_account=recipient).first()
|
||||
if not user_pref or not user_pref.email_reminder_enabled:
|
||||
continue
|
||||
|
||||
if email_service.send_reminder_email(todo, recipient, 'overdue'):
|
||||
sent_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send overdue reminder to {recipient}: {str(e)}")
|
||||
|
||||
# 記錄稽核日誌
|
||||
audit = TodoAuditLog(
|
||||
actor_ad='system',
|
||||
todo_id=None,
|
||||
action='DAILY_REMINDER',
|
||||
detail={
|
||||
'due_tomorrow_count': len(due_tomorrow),
|
||||
'overdue_count': len(overdue),
|
||||
'emails_sent': sent_count
|
||||
}
|
||||
)
|
||||
db.session.add(audit)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Daily reminders sent: {sent_count} emails for {len(due_tomorrow + overdue)} todos")
|
||||
return {
|
||||
'sent_count': sent_count,
|
||||
'due_tomorrow': len(due_tomorrow),
|
||||
'overdue': len(overdue)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Daily reminders task failed: {str(e)}")
|
||||
raise
|
||||
|
||||
@celery.task
|
||||
def send_weekly_summary():
|
||||
"""發送每週摘要報告"""
|
||||
try:
|
||||
with flask_app.app_context():
|
||||
# 取得所有啟用週報的用戶
|
||||
users = TodoUserPref.query.filter_by(weekly_summary_enabled=True).all()
|
||||
|
||||
email_service = EmailService()
|
||||
notification_service = NotificationService()
|
||||
sent_count = 0
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
# 準備週報資料
|
||||
digest_data = notification_service.prepare_digest(user.ad_account, 'weekly')
|
||||
|
||||
if email_service.send_digest_email(user.ad_account, digest_data):
|
||||
sent_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send weekly summary to {user.ad_account}: {str(e)}")
|
||||
|
||||
# 記錄稽核日誌
|
||||
audit = TodoAuditLog(
|
||||
actor_ad='system',
|
||||
todo_id=None,
|
||||
action='WEEKLY_SUMMARY',
|
||||
detail={
|
||||
'users_count': len(users),
|
||||
'emails_sent': sent_count
|
||||
}
|
||||
)
|
||||
db.session.add(audit)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Weekly summary sent: {sent_count} emails to {len(users)} users")
|
||||
return {
|
||||
'sent_count': sent_count,
|
||||
'total_users': len(users)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Weekly summary task failed: {str(e)}")
|
||||
raise
|
||||
|
||||
@celery.task
|
||||
def cleanup_old_logs():
|
||||
"""清理舊的日誌記錄"""
|
||||
try:
|
||||
with flask_app.app_context():
|
||||
# 清理30天前的稽核日誌
|
||||
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
|
||||
deleted_count = TodoAuditLog.query.filter(
|
||||
TodoAuditLog.created_at < thirty_days_ago
|
||||
).delete()
|
||||
|
||||
db.session.commit()
|
||||
logger.info(f"Cleaned up {deleted_count} old audit logs")
|
||||
return {'deleted_count': deleted_count}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Cleanup logs task failed: {str(e)}")
|
||||
raise
|
||||
|
||||
# Celery Beat 排程配置
|
||||
celery.conf.beat_schedule = {
|
||||
# 每日早上9點發送提醒
|
||||
'daily-reminders': {
|
||||
'task': 'tasks.send_daily_reminders',
|
||||
'schedule': 60.0 * 60.0 * 24.0, # 24小時
|
||||
'options': {'expires': 3600}
|
||||
},
|
||||
# 每週一早上9點發送週報
|
||||
'weekly-summary': {
|
||||
'task': 'tasks.send_weekly_summary',
|
||||
'schedule': 60.0 * 60.0 * 24.0 * 7.0, # 7天
|
||||
'options': {'expires': 3600}
|
||||
},
|
||||
# 每週清理一次舊日誌
|
||||
'cleanup-logs': {
|
||||
'task': 'tasks.cleanup_old_logs',
|
||||
'schedule': 60.0 * 60.0 * 24.0 * 7.0, # 7天
|
||||
'options': {'expires': 3600}
|
||||
}
|
||||
}
|
||||
|
||||
celery.conf.timezone = 'Asia/Taipei'
|
178
backend/tasks_simple.py
Normal file
178
backend/tasks_simple.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
Simple Task Definitions
|
||||
簡化的任務定義,避免循環導入
|
||||
"""
|
||||
|
||||
from datetime import datetime, date, timedelta
|
||||
from sqlalchemy import and_, or_
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
def send_daily_reminders_task():
|
||||
"""發送每日提醒郵件的實際實作"""
|
||||
from models import db, TodoItem, TodoUserPref
|
||||
from utils.email_service import EmailService
|
||||
from utils.notification_service import NotificationService
|
||||
|
||||
try:
|
||||
today = date.today()
|
||||
tomorrow = today + timedelta(days=1)
|
||||
|
||||
# 查找明日到期的待辦事項
|
||||
due_tomorrow = db.session.query(TodoItem).filter(
|
||||
and_(
|
||||
TodoItem.due_date == tomorrow,
|
||||
TodoItem.status != 'DONE'
|
||||
)
|
||||
).all()
|
||||
|
||||
# 查找已逾期的待辦事項
|
||||
overdue = db.session.query(TodoItem).filter(
|
||||
and_(
|
||||
TodoItem.due_date < today,
|
||||
TodoItem.status != 'DONE'
|
||||
)
|
||||
).all()
|
||||
|
||||
email_service = EmailService()
|
||||
notification_service = NotificationService()
|
||||
|
||||
sent_count = 0
|
||||
|
||||
# 處理明日到期提醒
|
||||
for todo in due_tomorrow:
|
||||
recipients = notification_service.get_notification_recipients(todo)
|
||||
for recipient in recipients:
|
||||
try:
|
||||
# 檢查用戶是否啟用郵件提醒
|
||||
user_pref = TodoUserPref.query.filter_by(ad_account=recipient).first()
|
||||
if not user_pref or not user_pref.email_reminder_enabled:
|
||||
continue
|
||||
|
||||
if email_service.send_reminder_email(todo, recipient, 'due_tomorrow'):
|
||||
sent_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send due tomorrow reminder to {recipient}: {str(e)}")
|
||||
|
||||
# 處理逾期提醒
|
||||
for todo in overdue:
|
||||
recipients = notification_service.get_notification_recipients(todo)
|
||||
for recipient in recipients:
|
||||
try:
|
||||
# 檢查用戶是否啟用郵件提醒
|
||||
user_pref = TodoUserPref.query.filter_by(ad_account=recipient).first()
|
||||
if not user_pref or not user_pref.email_reminder_enabled:
|
||||
continue
|
||||
|
||||
if email_service.send_reminder_email(todo, recipient, 'overdue'):
|
||||
sent_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send overdue reminder to {recipient}: {str(e)}")
|
||||
|
||||
# 記錄稽核日誌
|
||||
from models import TodoAuditLog
|
||||
audit = TodoAuditLog(
|
||||
actor_ad='system',
|
||||
todo_id=None,
|
||||
action='DAILY_REMINDER',
|
||||
detail={
|
||||
'due_tomorrow_count': len(due_tomorrow),
|
||||
'overdue_count': len(overdue),
|
||||
'emails_sent': sent_count
|
||||
}
|
||||
)
|
||||
db.session.add(audit)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Daily reminders sent: {sent_count} emails for {len(due_tomorrow + overdue)} todos")
|
||||
return {
|
||||
'sent_count': sent_count,
|
||||
'due_tomorrow': len(due_tomorrow),
|
||||
'overdue': len(overdue)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Daily reminders task failed: {str(e)}")
|
||||
raise
|
||||
|
||||
def send_weekly_summary_task():
|
||||
"""發送每週摘要報告的實際實作"""
|
||||
from models import db, TodoUserPref
|
||||
from utils.email_service import EmailService
|
||||
from utils.notification_service import NotificationService
|
||||
|
||||
try:
|
||||
# 取得所有啟用週報的用戶
|
||||
users = TodoUserPref.query.filter_by(weekly_summary_enabled=True).all()
|
||||
|
||||
email_service = EmailService()
|
||||
notification_service = NotificationService()
|
||||
sent_count = 0
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
# 準備週報資料
|
||||
digest_data = notification_service.prepare_digest(user.ad_account, 'weekly')
|
||||
|
||||
if email_service.send_digest_email(user.ad_account, digest_data):
|
||||
sent_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send weekly summary to {user.ad_account}: {str(e)}")
|
||||
|
||||
# 記錄稽核日誌
|
||||
from models import TodoAuditLog
|
||||
audit = TodoAuditLog(
|
||||
actor_ad='system',
|
||||
todo_id=None,
|
||||
action='WEEKLY_SUMMARY',
|
||||
detail={
|
||||
'users_count': len(users),
|
||||
'emails_sent': sent_count
|
||||
}
|
||||
)
|
||||
db.session.add(audit)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Weekly summary sent: {sent_count} emails to {len(users)} users")
|
||||
return {
|
||||
'sent_count': sent_count,
|
||||
'total_users': len(users)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Weekly summary task failed: {str(e)}")
|
||||
raise
|
||||
|
||||
def cleanup_old_logs_task():
|
||||
"""清理舊的日誌記錄的實際實作"""
|
||||
from models import db, TodoAuditLog
|
||||
|
||||
try:
|
||||
# 清理30天前的稽核日誌
|
||||
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
|
||||
deleted_count = TodoAuditLog.query.filter(
|
||||
TodoAuditLog.created_at < thirty_days_ago
|
||||
).delete()
|
||||
|
||||
db.session.commit()
|
||||
logger.info(f"Cleaned up {deleted_count} old audit logs")
|
||||
return {'deleted_count': deleted_count}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Cleanup logs task failed: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
# 為了與現有代碼兼容,提供簡單的包裝函數
|
||||
def send_daily_reminders():
|
||||
"""包裝函數,保持與現有代碼兼容"""
|
||||
return send_daily_reminders_task()
|
||||
|
||||
def send_weekly_summary():
|
||||
"""包裝函數,保持與現有代碼兼容"""
|
||||
return send_weekly_summary_task()
|
||||
|
||||
def cleanup_old_logs():
|
||||
"""包裝函數,保持與現有代碼兼容"""
|
||||
return cleanup_old_logs_task()
|
230
backend/templates/emails/fire_email.html
Normal file
230
backend/templates/emails/fire_email.html
Normal file
@@ -0,0 +1,230 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>緊急通知 - {{ todo.title }}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Microsoft YaHei', 'PingFang SC', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
border-bottom: 3px solid #dc3545;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.fire-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
.title {
|
||||
color: #dc3545;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
}
|
||||
.urgent-badge {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.todo-details {
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid #dc3545;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
.detail-row {
|
||||
margin: 10px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.detail-label {
|
||||
font-weight: bold;
|
||||
min-width: 80px;
|
||||
color: #666;
|
||||
}
|
||||
.detail-value {
|
||||
flex: 1;
|
||||
}
|
||||
.status-badge, .priority-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.status-new { background: #e3f2fd; color: #1976d2; }
|
||||
.status-in-progress { background: #fff3e0; color: #f57c00; }
|
||||
.status-done { background: #e8f5e8; color: #388e3c; }
|
||||
.priority-high { background: #ffebee; color: #d32f2f; }
|
||||
.priority-medium { background: #fff3e0; color: #f57c00; }
|
||||
.priority-low { background: #e8f5e8; color: #388e3c; }
|
||||
.custom-message {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.sender-info {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.action-buttons {
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
}
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
margin: 0 10px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #dee2e6;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
.timestamp {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<span class="fire-icon">🚨</span>
|
||||
<h1 class="title">緊急通知</h1>
|
||||
<div class="urgent-badge">URGENT - 立即處理</div>
|
||||
</div>
|
||||
|
||||
<div class="sender-info">
|
||||
<strong>{{ sender_name }}</strong> 向您發送了緊急通知
|
||||
<div class="timestamp">{{ timestamp }}</div>
|
||||
</div>
|
||||
|
||||
{% if custom_message %}
|
||||
<div class="custom-message">
|
||||
<strong>📝 發送者留言:</strong><br>
|
||||
{{ custom_message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="todo-details">
|
||||
<h3>📋 待辦事項詳情</h3>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">標題:</div>
|
||||
<div class="detail-value"><strong>{{ todo.title }}</strong></div>
|
||||
</div>
|
||||
|
||||
{% if todo.description %}
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">描述:</div>
|
||||
<div class="detail-value">{{ todo.description }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">狀態:</div>
|
||||
<div class="detail-value">
|
||||
<span class="status-badge status-{{ todo.status.lower().replace('_', '-') }}">
|
||||
{% if todo.status == 'NEW' %}新建
|
||||
{% elif todo.status == 'IN_PROGRESS' %}進行中
|
||||
{% elif todo.status == 'DONE' %}完成
|
||||
{% else %}{{ todo.status }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">優先級:</div>
|
||||
<div class="detail-value">
|
||||
<span class="priority-badge priority-{{ todo.priority.lower() }}">
|
||||
{% if todo.priority == 'HIGH' %}高
|
||||
{% elif todo.priority == 'MEDIUM' %}中
|
||||
{% elif todo.priority == 'LOW' %}低
|
||||
{% else %}{{ todo.priority }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if todo.due_date %}
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">到期日:</div>
|
||||
<div class="detail-value">
|
||||
<strong style="color: #dc3545;">{{ todo.due_date.strftime('%Y年%m月%d日') }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">建立者:</div>
|
||||
<div class="detail-value">{{ todo.creator_display_name or todo.creator_ad }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">建立時間:</div>
|
||||
<div class="detail-value">{{ todo.created_at.strftime('%Y年%m月%d日 %H:%M') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<a href="{{ app_url }}/todos/{{ todo.id }}" class="btn btn-primary">
|
||||
📖 查看詳情
|
||||
</a>
|
||||
<a href="{{ app_url }}/todos/{{ todo.id }}/edit" class="btn btn-danger">
|
||||
✏️ 立即處理
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>這是一封系統自動發送的緊急通知郵件</p>
|
||||
<p>如有疑問,請聯繫發送者 {{ sender_name }} ({{ sender }})</p>
|
||||
<div class="timestamp">
|
||||
發送時間:{{ timestamp }}<br>
|
||||
由 {{ app_name }} 系統發送
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
319
backend/utils/email_service.py
Normal file
319
backend/utils/email_service.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
Email Service
|
||||
處理所有郵件相關功能,包括通知、提醒和摘要郵件
|
||||
"""
|
||||
|
||||
import os
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.application import MIMEApplication
|
||||
from datetime import datetime, date
|
||||
from flask import current_app
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from utils.logger import get_logger
|
||||
from utils.ldap_utils import get_user_info
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class EmailService:
|
||||
"""郵件服務類別"""
|
||||
|
||||
def __init__(self):
|
||||
self.smtp_server = os.getenv('SMTP_SERVER')
|
||||
self.smtp_port = int(os.getenv('SMTP_PORT', 587))
|
||||
self.use_tls = os.getenv('SMTP_USE_TLS', 'false').lower() == 'true'
|
||||
self.use_ssl = os.getenv('SMTP_USE_SSL', 'false').lower() == 'true'
|
||||
self.auth_required = os.getenv('SMTP_AUTH_REQUIRED', 'false').lower() == 'true'
|
||||
self.sender_email = os.getenv('SMTP_SENDER_EMAIL')
|
||||
self.sender_password = os.getenv('SMTP_SENDER_PASSWORD', '')
|
||||
|
||||
# 設定 Jinja2 模板環境
|
||||
template_dir = os.path.join(os.path.dirname(__file__), '..', 'templates', 'emails')
|
||||
self.jinja_env = Environment(
|
||||
loader=FileSystemLoader(template_dir),
|
||||
autoescape=select_autoescape(['html', 'xml'])
|
||||
)
|
||||
|
||||
def _create_smtp_connection(self):
|
||||
"""建立 SMTP 連線"""
|
||||
try:
|
||||
if self.use_ssl:
|
||||
server = smtplib.SMTP_SSL(self.smtp_server, self.smtp_port)
|
||||
else:
|
||||
server = smtplib.SMTP(self.smtp_server, self.smtp_port)
|
||||
if self.use_tls:
|
||||
server.starttls()
|
||||
|
||||
if self.auth_required and self.sender_password:
|
||||
server.login(self.sender_email, self.sender_password)
|
||||
|
||||
return server
|
||||
except Exception as e:
|
||||
logger.error(f"SMTP connection failed: {str(e)}")
|
||||
return None
|
||||
|
||||
def _send_email(self, to_email, subject, html_content, text_content=None):
|
||||
"""發送郵件的基礎方法"""
|
||||
try:
|
||||
if not self.smtp_server or not self.sender_email:
|
||||
logger.error("SMTP configuration incomplete")
|
||||
return False
|
||||
|
||||
# 建立郵件
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['From'] = self.sender_email
|
||||
msg['To'] = to_email
|
||||
msg['Subject'] = subject
|
||||
|
||||
# 添加文本內容
|
||||
if text_content:
|
||||
text_part = MIMEText(text_content, 'plain', 'utf-8')
|
||||
msg.attach(text_part)
|
||||
|
||||
# 添加 HTML 內容
|
||||
html_part = MIMEText(html_content, 'html', 'utf-8')
|
||||
msg.attach(html_part)
|
||||
|
||||
# 發送郵件
|
||||
server = self._create_smtp_connection()
|
||||
if not server:
|
||||
return False
|
||||
|
||||
server.send_message(msg)
|
||||
server.quit()
|
||||
|
||||
logger.info(f"Email sent successfully to {to_email}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email to {to_email}: {str(e)}")
|
||||
return False
|
||||
|
||||
def _get_user_email(self, ad_account):
|
||||
"""取得使用者郵件地址"""
|
||||
user_info = get_user_info(ad_account)
|
||||
if user_info and user_info.get('email'):
|
||||
return user_info['email']
|
||||
|
||||
# 如果無法從 LDAP 取得,嘗試組合郵件地址
|
||||
domain = os.getenv('LDAP_DOMAIN', 'panjit.com.tw')
|
||||
return f"{ad_account}@{domain}"
|
||||
|
||||
def send_fire_email(self, todo, recipient, sender, custom_message=''):
|
||||
"""發送緊急通知郵件"""
|
||||
try:
|
||||
recipient_email = self._get_user_email(recipient)
|
||||
sender_info = get_user_info(sender)
|
||||
sender_name = sender_info.get('displayName', sender) if sender_info else sender
|
||||
|
||||
# 準備模板資料
|
||||
template_data = {
|
||||
'todo': todo,
|
||||
'recipient': recipient,
|
||||
'sender': sender,
|
||||
'sender_name': sender_name,
|
||||
'custom_message': custom_message,
|
||||
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'app_name': current_app.config.get('APP_NAME', 'PANJIT Todo List')
|
||||
}
|
||||
|
||||
# 渲染模板
|
||||
template = self.jinja_env.get_template('fire_email.html')
|
||||
html_content = template.render(**template_data)
|
||||
|
||||
# 主題
|
||||
subject = f"🚨 緊急通知 - {todo.title}"
|
||||
|
||||
return self._send_email(recipient_email, subject, html_content)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fire email failed for {recipient}: {str(e)}")
|
||||
return False
|
||||
|
||||
def send_reminder_email(self, todo, recipient, reminder_type):
|
||||
"""發送提醒郵件"""
|
||||
try:
|
||||
recipient_email = self._get_user_email(recipient)
|
||||
|
||||
# 根據提醒類型設定主題和模板
|
||||
if reminder_type == 'due_tomorrow':
|
||||
subject = f"📅 明日到期提醒 - {todo.title}"
|
||||
template_name = 'reminder_due_tomorrow.html'
|
||||
elif reminder_type == 'overdue':
|
||||
subject = f"⚠️ 逾期提醒 - {todo.title}"
|
||||
template_name = 'reminder_overdue.html'
|
||||
else:
|
||||
subject = f"📋 待辦提醒 - {todo.title}"
|
||||
template_name = 'reminder_general.html'
|
||||
|
||||
# 準備模板資料
|
||||
template_data = {
|
||||
'todo': todo,
|
||||
'recipient': recipient,
|
||||
'reminder_type': reminder_type,
|
||||
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'app_name': current_app.config.get('APP_NAME', 'PANJIT Todo List')
|
||||
}
|
||||
|
||||
# 渲染模板
|
||||
template = self.jinja_env.get_template(template_name)
|
||||
html_content = template.render(**template_data)
|
||||
|
||||
return self._send_email(recipient_email, subject, html_content)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Reminder email failed for {recipient}: {str(e)}")
|
||||
return False
|
||||
|
||||
def send_digest_email(self, recipient, digest_data):
|
||||
"""發送摘要郵件"""
|
||||
try:
|
||||
recipient_email = self._get_user_email(recipient)
|
||||
|
||||
# 根據摘要類型設定主題
|
||||
digest_type = digest_data.get('type', 'weekly')
|
||||
type_names = {
|
||||
'daily': '每日',
|
||||
'weekly': '每週',
|
||||
'monthly': '每月'
|
||||
}
|
||||
subject = f"📊 {type_names.get(digest_type, '定期')}摘要報告"
|
||||
|
||||
# 準備模板資料
|
||||
template_data = {
|
||||
'recipient': recipient,
|
||||
'digest_data': digest_data,
|
||||
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'app_name': current_app.config.get('APP_NAME', 'PANJIT Todo List')
|
||||
}
|
||||
|
||||
# 渲染模板
|
||||
template = self.jinja_env.get_template('digest.html')
|
||||
html_content = template.render(**template_data)
|
||||
|
||||
return self._send_email(recipient_email, subject, html_content)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Digest email failed for {recipient}: {str(e)}")
|
||||
return False
|
||||
|
||||
def send_todo_notification(self, todo, recipients, action, actor):
|
||||
"""發送待辦事項變更通知"""
|
||||
try:
|
||||
success_count = 0
|
||||
|
||||
for recipient in recipients:
|
||||
try:
|
||||
recipient_email = self._get_user_email(recipient)
|
||||
actor_info = get_user_info(actor)
|
||||
actor_name = actor_info.get('displayName', actor) if actor_info else actor
|
||||
|
||||
# 根據動作類型設定主題和模板
|
||||
action_names = {
|
||||
'CREATE': '建立',
|
||||
'UPDATE': '更新',
|
||||
'DELETE': '刪除',
|
||||
'ASSIGN': '指派',
|
||||
'COMPLETE': '完成'
|
||||
}
|
||||
|
||||
action_name = action_names.get(action, action)
|
||||
subject = f"📋 待辦事項{action_name} - {todo.title}"
|
||||
|
||||
# 準備模板資料
|
||||
template_data = {
|
||||
'todo': todo,
|
||||
'recipient': recipient,
|
||||
'action': action,
|
||||
'action_name': action_name,
|
||||
'actor': actor,
|
||||
'actor_name': actor_name,
|
||||
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'app_name': current_app.config.get('APP_NAME', 'PANJIT Todo List')
|
||||
}
|
||||
|
||||
# 渲染模板
|
||||
template = self.jinja_env.get_template('todo_notification.html')
|
||||
html_content = template.render(**template_data)
|
||||
|
||||
if self._send_email(recipient_email, subject, html_content):
|
||||
success_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Todo notification failed for {recipient}: {str(e)}")
|
||||
|
||||
return success_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Todo notification batch failed: {str(e)}")
|
||||
return 0
|
||||
|
||||
def send_test_email(self, recipient):
|
||||
"""發送測試郵件"""
|
||||
try:
|
||||
recipient_email = self._get_user_email(recipient)
|
||||
|
||||
subject = "✅ 郵件服務測試"
|
||||
html_content = f"""
|
||||
<html>
|
||||
<body>
|
||||
<h2>郵件服務測試</h2>
|
||||
<p>您好 {recipient},</p>
|
||||
<p>這是一封測試郵件,用於驗證 PANJIT Todo List 系統的郵件功能是否正常運作。</p>
|
||||
<p>如果您收到這封郵件,表示郵件服務配置正確。</p>
|
||||
<br>
|
||||
<p>測試時間: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
|
||||
<p>此郵件由系統自動發送,請勿回覆。</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return self._send_email(recipient_email, subject, html_content)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Test email failed for {recipient}: {str(e)}")
|
||||
return False
|
||||
|
||||
def send_test_email_direct(self, recipient_email):
|
||||
"""直接發送測試郵件到指定郵件地址"""
|
||||
try:
|
||||
subject = "✅ PANJIT Todo List 郵件服務測試"
|
||||
html_content = f"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #2563eb; border-bottom: 2px solid #e5e7eb; padding-bottom: 10px;">📧 郵件服務測試</h2>
|
||||
<p>您好!</p>
|
||||
<p>這是一封來自 <strong>PANJIT Todo List 系統</strong> 的測試郵件,用於驗證郵件服務功能是否正常運作。</p>
|
||||
|
||||
<div style="background-color: #f0f9ff; border-left: 4px solid #2563eb; padding: 15px; margin: 20px 0;">
|
||||
<p style="margin: 0;"><strong>✅ 如果您收到這封郵件,表示:</strong></p>
|
||||
<ul style="margin: 10px 0; padding-left: 20px;">
|
||||
<li>SMTP 服務器連線正常</li>
|
||||
<li>郵件發送功能運作良好</li>
|
||||
<li>您的郵件地址設定正確</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
|
||||
<p style="font-size: 14px; color: #6b7280;">
|
||||
<strong>測試詳細資訊:</strong><br>
|
||||
📅 測試時間: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}<br>
|
||||
📧 收件人: {recipient_email}<br>
|
||||
🏢 發件人: PANJIT Todo List 系統
|
||||
</p>
|
||||
|
||||
<p style="font-size: 12px; color: #9ca3af; margin-top: 30px;">
|
||||
此郵件由系統自動發送,請勿回覆。如有任何問題,請聯繫系統管理員。
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return self._send_email(recipient_email, subject, html_content)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Direct test email failed for {recipient_email}: {str(e)}")
|
||||
return False
|
230
backend/utils/ldap_utils.py
Normal file
230
backend/utils/ldap_utils.py
Normal file
@@ -0,0 +1,230 @@
|
||||
import time
|
||||
from ldap3 import Server, Connection, SUBTREE, ALL_ATTRIBUTES
|
||||
from flask import current_app
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
def create_ldap_connection(retries=3):
|
||||
"""Create LDAP connection with retry mechanism"""
|
||||
config = current_app.config
|
||||
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
server = Server(
|
||||
config['LDAP_SERVER'],
|
||||
port=config['LDAP_PORT'],
|
||||
use_ssl=config['LDAP_USE_SSL'],
|
||||
get_info=ALL_ATTRIBUTES
|
||||
)
|
||||
|
||||
conn = Connection(
|
||||
server,
|
||||
user=config['LDAP_BIND_USER_DN'],
|
||||
password=config['LDAP_BIND_USER_PASSWORD'],
|
||||
auto_bind=True,
|
||||
raise_exceptions=True
|
||||
)
|
||||
|
||||
logger.info("LDAP connection established successfully")
|
||||
return conn
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LDAP connection attempt {attempt + 1} failed: {str(e)}")
|
||||
if attempt == retries - 1:
|
||||
raise
|
||||
time.sleep(1)
|
||||
|
||||
return None
|
||||
|
||||
def authenticate_user(username, password):
|
||||
"""Authenticate user against LDAP/AD"""
|
||||
try:
|
||||
conn = create_ldap_connection()
|
||||
if not conn:
|
||||
return None
|
||||
|
||||
config = current_app.config
|
||||
search_filter = f"(&(objectClass=person)(objectCategory=person)({config['LDAP_USER_LOGIN_ATTR']}={username}))"
|
||||
|
||||
# Search for user
|
||||
conn.search(
|
||||
config['LDAP_SEARCH_BASE'],
|
||||
search_filter,
|
||||
SUBTREE,
|
||||
attributes=['displayName', 'mail', 'sAMAccountName', 'userPrincipalName']
|
||||
)
|
||||
|
||||
if not conn.entries:
|
||||
logger.warning(f"User not found: {username}")
|
||||
return None
|
||||
|
||||
user_entry = conn.entries[0]
|
||||
user_dn = user_entry.entry_dn
|
||||
|
||||
# Try to bind with user credentials
|
||||
try:
|
||||
user_conn = Connection(
|
||||
conn.server,
|
||||
user=user_dn,
|
||||
password=password,
|
||||
auto_bind=True,
|
||||
raise_exceptions=True
|
||||
)
|
||||
user_conn.unbind()
|
||||
|
||||
# Return user info
|
||||
user_info = {
|
||||
'ad_account': str(user_entry.sAMAccountName) if user_entry.sAMAccountName else username,
|
||||
'display_name': str(user_entry.displayName) if user_entry.displayName else username,
|
||||
'email': str(user_entry.mail) if user_entry.mail else '',
|
||||
'user_principal_name': str(user_entry.userPrincipalName) if user_entry.userPrincipalName else username
|
||||
}
|
||||
|
||||
logger.info(f"User authenticated successfully: {username}")
|
||||
return user_info
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Authentication failed for user {username}: {str(e)}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LDAP authentication error: {str(e)}")
|
||||
return None
|
||||
finally:
|
||||
if conn:
|
||||
conn.unbind()
|
||||
|
||||
def search_ldap_principals(search_term, limit=20):
|
||||
"""Search for LDAP users and groups"""
|
||||
try:
|
||||
conn = create_ldap_connection()
|
||||
if not conn:
|
||||
return []
|
||||
|
||||
config = current_app.config
|
||||
|
||||
# Build search filter for active users
|
||||
search_filter = f"""(&
|
||||
(objectClass=person)
|
||||
(objectCategory=person)
|
||||
(!(userAccountControl:1.2.840.113556.1.4.803:=2))
|
||||
(|
|
||||
(displayName=*{search_term}*)
|
||||
(mail=*{search_term}*)
|
||||
(sAMAccountName=*{search_term}*)
|
||||
(userPrincipalName=*{search_term}*)
|
||||
)
|
||||
)"""
|
||||
|
||||
# Remove extra whitespace
|
||||
search_filter = ' '.join(search_filter.split())
|
||||
|
||||
conn.search(
|
||||
config['LDAP_SEARCH_BASE'],
|
||||
search_filter,
|
||||
SUBTREE,
|
||||
attributes=['sAMAccountName', 'displayName', 'mail'],
|
||||
size_limit=limit
|
||||
)
|
||||
|
||||
results = []
|
||||
for entry in conn.entries:
|
||||
results.append({
|
||||
'ad_account': str(entry.sAMAccountName) if entry.sAMAccountName else '',
|
||||
'display_name': str(entry.displayName) if entry.displayName else '',
|
||||
'email': str(entry.mail) if entry.mail else ''
|
||||
})
|
||||
|
||||
logger.info(f"LDAP search found {len(results)} results for term: {search_term}")
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LDAP search error: {str(e)}")
|
||||
return []
|
||||
finally:
|
||||
if conn:
|
||||
conn.unbind()
|
||||
|
||||
def get_user_info(ad_account):
|
||||
"""Get user information from LDAP"""
|
||||
try:
|
||||
conn = create_ldap_connection()
|
||||
if not conn:
|
||||
return None
|
||||
|
||||
config = current_app.config
|
||||
search_filter = f"(&(objectClass=person)(sAMAccountName={ad_account}))"
|
||||
|
||||
conn.search(
|
||||
config['LDAP_SEARCH_BASE'],
|
||||
search_filter,
|
||||
SUBTREE,
|
||||
attributes=['displayName', 'mail', 'sAMAccountName', 'userPrincipalName']
|
||||
)
|
||||
|
||||
if not conn.entries:
|
||||
return None
|
||||
|
||||
entry = conn.entries[0]
|
||||
return {
|
||||
'ad_account': str(entry.sAMAccountName) if entry.sAMAccountName else ad_account,
|
||||
'display_name': str(entry.displayName) if entry.displayName else ad_account,
|
||||
'email': str(entry.mail) if entry.mail else ''
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user info for {ad_account}: {str(e)}")
|
||||
return None
|
||||
finally:
|
||||
if conn:
|
||||
conn.unbind()
|
||||
|
||||
def validate_ad_accounts(ad_accounts):
|
||||
"""Validate multiple AD accounts exist"""
|
||||
try:
|
||||
conn = create_ldap_connection()
|
||||
if not conn:
|
||||
return {}
|
||||
|
||||
config = current_app.config
|
||||
valid_accounts = {}
|
||||
|
||||
for account in ad_accounts:
|
||||
search_filter = f"(&(objectClass=person)(sAMAccountName={account}))"
|
||||
|
||||
conn.search(
|
||||
config['LDAP_SEARCH_BASE'],
|
||||
search_filter,
|
||||
SUBTREE,
|
||||
attributes=['sAMAccountName', 'displayName', 'mail']
|
||||
)
|
||||
|
||||
if conn.entries:
|
||||
entry = conn.entries[0]
|
||||
valid_accounts[account] = {
|
||||
'ad_account': str(entry.sAMAccountName) if entry.sAMAccountName else account,
|
||||
'display_name': str(entry.displayName) if entry.displayName else account,
|
||||
'email': str(entry.mail) if entry.mail else ''
|
||||
}
|
||||
|
||||
return valid_accounts
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating AD accounts: {str(e)}")
|
||||
return {}
|
||||
finally:
|
||||
if conn:
|
||||
conn.unbind()
|
||||
|
||||
def test_ldap_connection():
|
||||
"""Test LDAP connection for health check"""
|
||||
try:
|
||||
conn = create_ldap_connection(retries=1)
|
||||
if conn:
|
||||
conn.unbind()
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"LDAP connection test failed: {str(e)}")
|
||||
return False
|
58
backend/utils/logger.py
Normal file
58
backend/utils/logger.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import os
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from colorlog import ColoredFormatter
|
||||
|
||||
def setup_logger(app):
|
||||
"""Setup application logging"""
|
||||
|
||||
# Create logs directory if it doesn't exist
|
||||
log_dir = 'logs'
|
||||
if not os.path.exists(log_dir):
|
||||
os.makedirs(log_dir)
|
||||
|
||||
log_file = app.config.get('LOG_FILE', 'logs/app.log')
|
||||
log_level = app.config.get('LOG_LEVEL', 'INFO')
|
||||
|
||||
# Set up file handler
|
||||
file_handler = RotatingFileHandler(
|
||||
log_file,
|
||||
maxBytes=10485760, # 10MB
|
||||
backupCount=10
|
||||
)
|
||||
file_handler.setLevel(getattr(logging, log_level))
|
||||
|
||||
# File formatter
|
||||
file_formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
file_handler.setFormatter(file_formatter)
|
||||
|
||||
# Console handler with colors
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(getattr(logging, log_level))
|
||||
|
||||
# Console formatter with colors
|
||||
console_formatter = ColoredFormatter(
|
||||
'%(log_color)s%(asctime)s - %(name)s - %(levelname)s - %(message)s%(reset)s',
|
||||
log_colors={
|
||||
'DEBUG': 'cyan',
|
||||
'INFO': 'green',
|
||||
'WARNING': 'yellow',
|
||||
'ERROR': 'red',
|
||||
'CRITICAL': 'red,bg_white',
|
||||
}
|
||||
)
|
||||
console_handler.setFormatter(console_formatter)
|
||||
|
||||
# Add handlers to app logger
|
||||
app.logger.addHandler(file_handler)
|
||||
app.logger.addHandler(console_handler)
|
||||
app.logger.setLevel(getattr(logging, log_level))
|
||||
|
||||
# Log startup
|
||||
app.logger.info(f"Application started in {app.config.get('ENV', 'development')} mode")
|
||||
|
||||
def get_logger(name):
|
||||
"""Get a logger instance"""
|
||||
return logging.getLogger(name)
|
140
backend/utils/mock_ldap.py
Normal file
140
backend/utils/mock_ldap.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
Mock LDAP for development/testing purposes
|
||||
當無法連接到實際LDAP時使用
|
||||
"""
|
||||
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
def authenticate_user(username, password):
|
||||
"""Mock authentication for development"""
|
||||
logger.info(f"Mock LDAP: Authenticating user {username}")
|
||||
|
||||
# 簡單的開發用驗證
|
||||
if not username or not password:
|
||||
return None
|
||||
|
||||
# 模擬用戶資料
|
||||
mock_users = {
|
||||
'admin': {
|
||||
'ad_account': 'admin',
|
||||
'display_name': '系統管理員',
|
||||
'email': 'admin@panjit.com.tw'
|
||||
},
|
||||
'test': {
|
||||
'ad_account': 'test',
|
||||
'display_name': '測試使用者',
|
||||
'email': 'test@panjit.com.tw'
|
||||
},
|
||||
'user1': {
|
||||
'ad_account': 'user1',
|
||||
'display_name': '使用者一',
|
||||
'email': 'user1@panjit.com.tw'
|
||||
},
|
||||
'ymirliu@panjit.com.tw': {
|
||||
'ad_account': '92367',
|
||||
'display_name': 'ymirliu 陸一銘',
|
||||
'email': 'ymirliu@panjit.com.tw'
|
||||
}
|
||||
}
|
||||
|
||||
if username.lower() in mock_users:
|
||||
logger.info(f"Mock LDAP: User {username} authenticated successfully")
|
||||
return mock_users[username.lower()]
|
||||
|
||||
logger.warning(f"Mock LDAP: User {username} not found")
|
||||
return None
|
||||
|
||||
def search_ldap_principals(search_term, limit=20):
|
||||
"""Mock LDAP search"""
|
||||
logger.info(f"Mock LDAP: Searching for '{search_term}'")
|
||||
|
||||
mock_results = [
|
||||
{
|
||||
'ad_account': 'admin',
|
||||
'display_name': '系統管理員',
|
||||
'email': 'admin@panjit.com.tw'
|
||||
},
|
||||
{
|
||||
'ad_account': 'test',
|
||||
'display_name': '測試使用者',
|
||||
'email': 'test@panjit.com.tw'
|
||||
},
|
||||
{
|
||||
'ad_account': 'user1',
|
||||
'display_name': '使用者一',
|
||||
'email': 'user1@panjit.com.tw'
|
||||
},
|
||||
{
|
||||
'ad_account': 'user2',
|
||||
'display_name': '使用者二',
|
||||
'email': 'user2@panjit.com.tw'
|
||||
}
|
||||
]
|
||||
|
||||
# 簡單的搜尋過濾
|
||||
if search_term:
|
||||
results = []
|
||||
for user in mock_results:
|
||||
if (search_term.lower() in user['ad_account'].lower() or
|
||||
search_term.lower() in user['display_name'].lower() or
|
||||
search_term.lower() in user['email'].lower()):
|
||||
results.append(user)
|
||||
return results[:limit]
|
||||
|
||||
return mock_results[:limit]
|
||||
|
||||
def get_user_info(ad_account):
|
||||
"""Mock get user info"""
|
||||
mock_users = {
|
||||
'admin': {
|
||||
'ad_account': 'admin',
|
||||
'display_name': '系統管理員',
|
||||
'email': 'admin@panjit.com.tw'
|
||||
},
|
||||
'test': {
|
||||
'ad_account': 'test',
|
||||
'display_name': '測試使用者',
|
||||
'email': 'test@panjit.com.tw'
|
||||
},
|
||||
'user1': {
|
||||
'ad_account': 'user1',
|
||||
'display_name': '使用者一',
|
||||
'email': 'user1@panjit.com.tw'
|
||||
}
|
||||
}
|
||||
|
||||
return mock_users.get(ad_account.lower())
|
||||
|
||||
def validate_ad_accounts(ad_accounts):
|
||||
"""Mock validate AD accounts"""
|
||||
mock_users = {
|
||||
'admin': {
|
||||
'ad_account': 'admin',
|
||||
'display_name': '系統管理員',
|
||||
'email': 'admin@panjit.com.tw'
|
||||
},
|
||||
'test': {
|
||||
'ad_account': 'test',
|
||||
'display_name': '測試使用者',
|
||||
'email': 'test@panjit.com.tw'
|
||||
},
|
||||
'user1': {
|
||||
'ad_account': 'user1',
|
||||
'display_name': '使用者一',
|
||||
'email': 'user1@panjit.com.tw'
|
||||
}
|
||||
}
|
||||
|
||||
valid_accounts = {}
|
||||
for account in ad_accounts:
|
||||
if account.lower() in mock_users:
|
||||
valid_accounts[account] = mock_users[account.lower()]
|
||||
|
||||
return valid_accounts
|
||||
|
||||
def test_ldap_connection():
|
||||
"""Mock LDAP connection test"""
|
||||
logger.info("Mock LDAP: Connection test - always returns True")
|
||||
return True
|
225
backend/utils/notification_service.py
Normal file
225
backend/utils/notification_service.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
Notification Service
|
||||
處理通知邏輯和摘要資料準備
|
||||
"""
|
||||
|
||||
from datetime import datetime, date, timedelta
|
||||
from sqlalchemy import and_, or_, func
|
||||
from models import (
|
||||
db, TodoItem, TodoItemResponsible, TodoItemFollower,
|
||||
TodoUserPref, TodoAuditLog
|
||||
)
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class NotificationService:
|
||||
"""通知服務類別"""
|
||||
|
||||
def get_notification_recipients(self, todo):
|
||||
"""取得待辦事項的通知收件人清單"""
|
||||
recipients = set()
|
||||
|
||||
# 加入建立者(如果啟用通知)
|
||||
creator_pref = TodoUserPref.query.filter_by(ad_account=todo.creator_ad).first()
|
||||
if creator_pref and creator_pref.notification_enabled:
|
||||
recipients.add(todo.creator_ad)
|
||||
|
||||
# 加入負責人(如果啟用通知)
|
||||
for responsible in todo.responsible_users:
|
||||
user_pref = TodoUserPref.query.filter_by(ad_account=responsible.ad_account).first()
|
||||
if user_pref and user_pref.notification_enabled:
|
||||
recipients.add(responsible.ad_account)
|
||||
|
||||
# 加入追蹤人(如果啟用通知)
|
||||
for follower in todo.followers:
|
||||
user_pref = TodoUserPref.query.filter_by(ad_account=follower.ad_account).first()
|
||||
if user_pref and user_pref.notification_enabled:
|
||||
recipients.add(follower.ad_account)
|
||||
|
||||
return list(recipients)
|
||||
|
||||
def prepare_digest(self, user_ad, digest_type='weekly'):
|
||||
"""準備摘要資料"""
|
||||
try:
|
||||
# 計算日期範圍
|
||||
today = date.today()
|
||||
|
||||
if digest_type == 'daily':
|
||||
start_date = today
|
||||
end_date = today
|
||||
period_name = '今日'
|
||||
elif digest_type == 'weekly':
|
||||
start_date = today - timedelta(days=today.weekday()) # 週一
|
||||
end_date = start_date + timedelta(days=6) # 週日
|
||||
period_name = '本週'
|
||||
elif digest_type == 'monthly':
|
||||
start_date = today.replace(day=1)
|
||||
next_month = today.replace(day=28) + timedelta(days=4)
|
||||
end_date = next_month - timedelta(days=next_month.day)
|
||||
period_name = '本月'
|
||||
else:
|
||||
raise ValueError(f"Unsupported digest type: {digest_type}")
|
||||
|
||||
# 基礎查詢 - 使用者相關的待辦事項
|
||||
base_query = TodoItem.query.filter(
|
||||
or_(
|
||||
TodoItem.creator_ad == user_ad,
|
||||
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == user_ad),
|
||||
TodoItem.followers.any(TodoItemFollower.ad_account == user_ad)
|
||||
)
|
||||
)
|
||||
|
||||
# 統計資料
|
||||
stats = {
|
||||
'total_todos': base_query.count(),
|
||||
'completed_todos': base_query.filter(TodoItem.status == 'DONE').count(),
|
||||
'doing_todos': base_query.filter(TodoItem.status == 'DOING').count(),
|
||||
'blocked_todos': base_query.filter(TodoItem.status == 'BLOCKED').count(),
|
||||
'new_todos': base_query.filter(TodoItem.status == 'NEW').count()
|
||||
}
|
||||
|
||||
# 期間內完成的待辦事項
|
||||
completed_in_period = base_query.filter(
|
||||
and_(
|
||||
TodoItem.status == 'DONE',
|
||||
func.date(TodoItem.completed_at).between(start_date, end_date)
|
||||
)
|
||||
).all()
|
||||
|
||||
# 期間內建立的待辦事項
|
||||
created_in_period = base_query.filter(
|
||||
func.date(TodoItem.created_at).between(start_date, end_date)
|
||||
).all()
|
||||
|
||||
# 即將到期的待辦事項(未來7天)
|
||||
upcoming_due = base_query.filter(
|
||||
and_(
|
||||
TodoItem.due_date.between(today, today + timedelta(days=7)),
|
||||
TodoItem.status != 'DONE'
|
||||
)
|
||||
).order_by(TodoItem.due_date).all()
|
||||
|
||||
# 逾期的待辦事項
|
||||
overdue = base_query.filter(
|
||||
and_(
|
||||
TodoItem.due_date < today,
|
||||
TodoItem.status != 'DONE'
|
||||
)
|
||||
).order_by(TodoItem.due_date).all()
|
||||
|
||||
# 高優先級待辦事項
|
||||
high_priority = base_query.filter(
|
||||
and_(
|
||||
TodoItem.priority == 'HIGH',
|
||||
TodoItem.status != 'DONE'
|
||||
)
|
||||
).all()
|
||||
|
||||
# 活動記錄(期間內的操作)
|
||||
activities = TodoAuditLog.query.filter(
|
||||
and_(
|
||||
TodoAuditLog.actor_ad == user_ad,
|
||||
func.date(TodoAuditLog.created_at).between(start_date, end_date)
|
||||
)
|
||||
).order_by(TodoAuditLog.created_at.desc()).limit(10).all()
|
||||
|
||||
# 組織摘要資料
|
||||
digest_data = {
|
||||
'type': digest_type,
|
||||
'period_name': period_name,
|
||||
'start_date': start_date,
|
||||
'end_date': end_date,
|
||||
'user_ad': user_ad,
|
||||
'stats': stats,
|
||||
'completed_in_period': [todo.to_dict() for todo in completed_in_period],
|
||||
'created_in_period': [todo.to_dict() for todo in created_in_period],
|
||||
'upcoming_due': [todo.to_dict() for todo in upcoming_due],
|
||||
'overdue': [todo.to_dict() for todo in overdue],
|
||||
'high_priority': [todo.to_dict() for todo in high_priority],
|
||||
'recent_activities': [
|
||||
{
|
||||
'action': activity.action,
|
||||
'created_at': activity.created_at,
|
||||
'detail': activity.detail,
|
||||
'todo_id': activity.todo_id
|
||||
}
|
||||
for activity in activities
|
||||
],
|
||||
'generated_at': datetime.now()
|
||||
}
|
||||
|
||||
return digest_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to prepare digest for {user_ad}: {str(e)}")
|
||||
raise
|
||||
|
||||
def should_send_notification(self, user_ad, notification_type):
|
||||
"""檢查是否應該發送通知"""
|
||||
try:
|
||||
user_pref = TodoUserPref.query.filter_by(ad_account=user_ad).first()
|
||||
if not user_pref:
|
||||
return False
|
||||
|
||||
# 檢查通知開關
|
||||
if notification_type == 'email_reminder':
|
||||
return user_pref.email_reminder_enabled
|
||||
elif notification_type == 'weekly_summary':
|
||||
return user_pref.weekly_summary_enabled
|
||||
elif notification_type == 'general':
|
||||
return user_pref.notification_enabled
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking notification settings for {user_ad}: {str(e)}")
|
||||
return False
|
||||
|
||||
def get_users_for_batch_notifications(self, notification_type):
|
||||
"""取得需要接收批量通知的使用者清單"""
|
||||
try:
|
||||
if notification_type == 'weekly_summary':
|
||||
users = db.session.query(TodoUserPref.ad_account).filter(
|
||||
TodoUserPref.weekly_summary_enabled == True
|
||||
).all()
|
||||
elif notification_type == 'email_reminder':
|
||||
users = db.session.query(TodoUserPref.ad_account).filter(
|
||||
TodoUserPref.email_reminder_enabled == True
|
||||
).all()
|
||||
else:
|
||||
users = db.session.query(TodoUserPref.ad_account).filter(
|
||||
TodoUserPref.notification_enabled == True
|
||||
).all()
|
||||
|
||||
return [user[0] for user in users]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting users for batch notifications: {str(e)}")
|
||||
return []
|
||||
|
||||
def create_notification_summary(self, todos, notification_type):
|
||||
"""建立通知摘要"""
|
||||
try:
|
||||
if notification_type == 'due_tomorrow':
|
||||
return {
|
||||
'title': '明日到期提醒',
|
||||
'description': f'您有 {len(todos)} 項待辦事項將於明日到期',
|
||||
'todos': [todo.to_dict() for todo in todos]
|
||||
}
|
||||
elif notification_type == 'overdue':
|
||||
return {
|
||||
'title': '逾期提醒',
|
||||
'description': f'您有 {len(todos)} 項待辦事項已逾期',
|
||||
'todos': [todo.to_dict() for todo in todos]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'title': '待辦事項提醒',
|
||||
'description': f'您有 {len(todos)} 項待辦事項需要關注',
|
||||
'todos': [todo.to_dict() for todo in todos]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating notification summary: {str(e)}")
|
||||
return None
|
Reference in New Issue
Block a user