1ST
This commit is contained in:
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