Files
TODO_list_system/backend/routes/notifications.py
beabigegg b0c86302ff 1ST
2025-08-29 16:25:46 +08:00

584 lines
22 KiB
Python

"""
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