584 lines
22 KiB
Python
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 |