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