feat: add email notice and settings page but still have some issue

This commit is contained in:
2025-09-20 13:09:06 +08:00
parent 8f2ab9aeda
commit b715c8bb52
15 changed files with 1595 additions and 3 deletions
+2
View File
@@ -0,0 +1,2 @@
+22
View File
@@ -0,0 +1,22 @@
from django.contrib import admin
from .models import NotificationEmail, NotificationSettings
@admin.register(NotificationEmail)
class NotificationEmailAdmin(admin.ModelAdmin):
list_display = ('email', 'is_enabled', 'description', 'created_at')
list_filter = ('is_enabled', 'created_at')
search_fields = ('email', 'description')
list_editable = ('is_enabled',)
ordering = ('-created_at',)
@admin.register(NotificationSettings)
class NotificationSettingsAdmin(admin.ModelAdmin):
list_display = ('email_notification_enabled', 'updated_at')
def has_add_permission(self, request):
# 只允许存在一个设置实例
return not NotificationSettings.objects.exists()
def has_delete_permission(self, request, obj=None):
# 不允许删除设置
return False
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class EmailConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'email_notice'
+297
View File
@@ -0,0 +1,297 @@
from django.utils.deprecation import MiddlewareMixin
from django.http import JsonResponse
from .services import EmailNotificationService
import json
import logging
import threading
from django.core.exceptions import ObjectDoesNotExist
logger = logging.getLogger(__name__)
class EmailNotificationMiddleware(MiddlewareMixin):
"""邮件通知中间件"""
def __init__(self, get_response):
self.get_response = get_response
super().__init__(get_response)
# 用于存储删除前的数据
self._delete_data_cache = {}
def process_request(self, request):
"""在处理请求前,如果是删除操作,先获取要删除的数据"""
try:
# 只处理DELETE操作
if request.method != 'DELETE':
return None
# 只处理API请求
if not request.path.startswith('/api/'):
return None
# 从URL路径中提取ID
path_parts = request.path.split('/')
record_id = None
for part in path_parts:
if part.isdigit():
record_id = part
break
if not record_id:
return None
# 根据路径类型获取对应的数据
if '/api/items/' in request.path:
data = self._get_item_data_before_delete(record_id)
if data:
self._delete_data_cache[f"item_{record_id}"] = data
elif '/api/records/' in request.path or '/api/finance/' in request.path:
data = self._get_finance_data_before_delete(record_id)
if data:
self._delete_data_cache[f"finance_{record_id}"] = data
except Exception as e:
logger.error(f"获取删除前数据失败: {e}")
return None
def _get_item_data_before_delete(self, item_id):
"""获取物品删除前的数据"""
try:
from items.models import Item
item = Item.objects.get(id=item_id)
return {
'id': item.id,
'name': item.name,
'serial_number': item.serial_number,
'status': item.status,
'category': item.category,
'owner': item.owner,
'purchase_date': str(item.purchase_date) if item.purchase_date else '',
'purchase_price': str(item.purchase_price) if item.purchase_price else '',
'description': item.description,
'updated_at': str(item.updated_at) if hasattr(item, 'updated_at') else '',
}
except Exception as e:
logger.error(f"获取物品数据失败: {e}")
return None
def _get_finance_data_before_delete(self, record_id):
"""获取财务记录删除前的数据"""
try:
from finance.models import FinanceRecord
record = FinanceRecord.objects.get(id=record_id)
return {
'id': record.id,
'amount': str(record.amount),
'transaction_type': record.transaction_type,
'description': record.description,
'department': record.department.name if record.department else '',
'category': record.category.name if record.category else '',
'approver': record.approver,
'transaction_date': str(record.transaction_date),
'proof_images': [str(img.image) for img in record.proof_images.all()] if hasattr(record, 'proof_images') else []
}
except Exception as e:
logger.error(f"获取财务记录数据失败: {e}")
return None
def process_response(self, request, response):
"""处理响应,在数据修改操作成功后异步发送邮件通知"""
try:
# 只处理API请求
if not request.path.startswith('/api/'):
return response
# 只处理数据修改操作
if request.method not in ['POST', 'PUT', 'PATCH', 'DELETE']:
return response
# 只处理成功的响应
if not (200 <= response.status_code < 300):
return response
# 获取用户信息
user_info = self._get_user_info(request)
# 异步处理邮件通知,避免阻塞API响应
if '/api/items/' in request.path:
self._handle_item_operation_async(request, response, user_info)
elif '/api/records/' in request.path or '/api/finance/' in request.path:
self._handle_finance_operation_async(request, response, user_info)
except Exception as e:
logger.error(f"邮件通知中间件处理失败: {e}")
return response
def _get_user_info(self, request):
"""获取用户信息"""
try:
if hasattr(request, 'user') and request.user.is_authenticated:
return f"{request.user.username} ({request.user.email})"
else:
return f"匿名用户 (IP: {self._get_client_ip(request)})"
except:
return "未知用户"
def _get_client_ip(self, request):
"""获取客户端IP"""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
def _handle_item_operation_async(self, request, response, user_info):
"""异步处理物品操作"""
def send_notification():
try:
operation_type = self._get_operation_type(request.method, request.path)
# 对于DELETE操作,使用预先获取的数据
if request.method == 'DELETE':
# 从URL路径中提取ID
path_parts = request.path.split('/')
item_id = None
for part in path_parts:
if part.isdigit():
item_id = part
break
# 尝试从缓存中获取删除前的数据
cached_data = self._delete_data_cache.get(f"item_{item_id}")
if cached_data:
notification_data = {
'id': cached_data.get('id', item_id),
'name': f"[已删除] {cached_data.get('name', '未知物品')}",
'serial_number': cached_data.get('serial_number', ''),
'status': f"[删除前状态: {cached_data.get('status', '未知')}]",
'category': cached_data.get('category', ''),
'owner': cached_data.get('owner', ''),
'purchase_date': cached_data.get('purchase_date', ''),
'purchase_price': cached_data.get('purchase_price', ''),
'description': f"删除前描述: {cached_data.get('description', '')}",
'timestamp': cached_data.get('updated_at', ''),
'operation_path': request.path,
'operation_method': request.method
}
else:
# 尝试从响应中获取数据
if hasattr(response, 'data'):
item_data = response.data
else:
try:
content = response.content.decode('utf-8')
item_data = json.loads(content) if content else {}
except:
item_data = {}
# 确保item_data不为None
if item_data is None:
item_data = {}
# 构建通知数据
notification_data = {
'id': item_data.get('id', ''),
'name': item_data.get('name', ''),
'serial_number': item_data.get('serial_number', ''),
'status': item_data.get('status', ''),
'category': item_data.get('category', ''),
'owner': item_data.get('owner', ''),
'timestamp': item_data.get('updated_at', ''),
'operation_path': request.path,
'operation_method': request.method
}
EmailNotificationService.send_operation_notification(
operation_type, '物品', notification_data, user_info
)
except Exception as e:
logger.error(f"异步处理物品操作通知失败: {e} 如果需要查看删除前的数据,请检查以前的邮件。")
# 创建并启动后台线程
thread = threading.Thread(target=send_notification)
thread.daemon = True # 设置为守护线程
thread.start()
def _handle_finance_operation_async(self, request, response, user_info):
"""异步处理财务操作"""
def send_notification():
try:
operation_type = self._get_operation_type(request.method, request.path)
# 对于DELETE操作,响应通常不包含数据,需要从URL路径中提取ID
if request.method == 'DELETE':
# 从URL路径中提取ID
path_parts = request.path.split('/')
record_id = None
for part in path_parts:
if part.isdigit():
record_id = part
break
notification_data = {
'id': record_id or '未知',
'amount': '已删除记录',
'transaction_type': '已删除',
'description': '财务记录已被删除',
'department': '未知',
'category': '未知',
'approver': '未知',
'timestamp': '删除时间未知',
'operation_path': request.path,
'operation_method': request.method
}
else:
# 尝试从响应中获取数据
if hasattr(response, 'data'):
finance_data = response.data
else:
try:
content = response.content.decode('utf-8')
finance_data = json.loads(content) if content else {}
except:
finance_data = {}
# 确保finance_data不为None
if finance_data is None:
finance_data = {}
# 构建通知数据
notification_data = {
'id': finance_data.get('id', ''),
'amount': finance_data.get('amount', ''),
'transaction_type': finance_data.get('transaction_type', ''),
'description': finance_data.get('description', ''),
'department': finance_data.get('department', ''),
'category': finance_data.get('category', ''),
'approver': finance_data.get('approver', ''),
'timestamp': finance_data.get('transaction_date', ''),
'operation_path': request.path,
'operation_method': request.method
}
EmailNotificationService.send_operation_notification(
operation_type, '财务记录', notification_data, user_info
)
except Exception as e:
logger.error(f"异步处理财务操作通知失败: {e} 如果需要查看删除前的数据,请检查以前的邮件。")
# 创建并启动后台线程
thread = threading.Thread(target=send_notification)
thread.daemon = True # 设置为守护线程
thread.start()
def _get_operation_type(self, method, path):
"""根据HTTP方法和路径判断操作类型"""
if method == 'POST':
return 'CREATE'
elif method in ['PUT', 'PATCH']:
return 'UPDATE'
elif method == 'DELETE':
return 'DELETE'
else:
return 'UNKNOWN'
+66
View File
@@ -0,0 +1,66 @@
from django.db import models
from django.core.validators import EmailValidator
class NotificationEmail(models.Model):
"""通知邮箱模型"""
email = models.EmailField(
unique=True,
validators=[EmailValidator()],
verbose_name='邮箱地址'
)
is_enabled = models.BooleanField(
default=True,
verbose_name='是否启用'
)
description = models.CharField(
max_length=200,
blank=True,
null=True,
verbose_name='描述'
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name='创建时间'
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name='更新时间'
)
class Meta:
verbose_name = '通知邮箱'
verbose_name_plural = '通知邮箱'
ordering = ['-created_at']
def __str__(self):
status = "启用" if self.is_enabled else "禁用"
return f"{self.email} ({status})"
class NotificationSettings(models.Model):
"""通知设置模型"""
email_notification_enabled = models.BooleanField(
default=True,
verbose_name='邮件通知总开关'
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name='创建时间'
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name='更新时间'
)
class Meta:
verbose_name = '通知设置'
verbose_name_plural = '通知设置'
def __str__(self):
status = "开启" if self.email_notification_enabled else "关闭"
return f"邮件通知: {status}"
@classmethod
def get_settings(cls):
"""获取通知设置(单例模式)"""
settings, created = cls.objects.get_or_create(pk=1)
return settings
+232
View File
@@ -0,0 +1,232 @@
from django.core.mail import send_mail
from django.conf import settings
import json
import logging
logger = logging.getLogger(__name__)
class EmailNotificationService:
"""邮件通知服务"""
@staticmethod
def get_notification_settings():
"""获取通知设置"""
try:
from .models import NotificationEmail, NotificationSettings
# 获取全局设置
global_settings = NotificationSettings.get_settings()
# 获取启用的邮箱列表
enabled_emails = NotificationEmail.objects.filter(is_enabled=True).values_list('email', flat=True)
return {
'email_enabled': global_settings.email_notification_enabled,
'notification_emails': list(enabled_emails)
}
except Exception as e:
logger.error(f"获取通知设置失败: {e}")
return {'email_enabled': False, 'notification_emails': []}
@staticmethod
def send_operation_notification(operation_type, model_name, instance_data, user_info=None):
"""
发送操作通知邮件到多个邮箱
Args:
operation_type: 操作类型 ('CREATE', 'UPDATE', 'DELETE')
model_name: 模型名称 (如 'Item', 'FinanceRecord')
instance_data: 实例数据
user_info: 操作用户信息
"""
try:
settings_data = EmailNotificationService.get_notification_settings()
if not settings_data.get('email_enabled') or not settings_data.get('notification_emails'):
return
notification_emails = settings_data.get('notification_emails', [])
if not notification_emails:
logger.warning("没有配置启用的通知邮箱")
return
# 构建邮件内容
subject = f"[爱特工作室管理系统] {model_name}数据变更通知"
operation_map = {
'CREATE': '创建',
'UPDATE': '更新',
'DELETE': '删除'
}
operation_text = operation_map.get(operation_type, operation_type)
message = f"""
系统数据变更通知
操作类型: {operation_text}
数据类型: {model_name}
操作时间: {instance_data.get('timestamp', '未知')}
操作用户: {user_info or '系统'}
变更详情:
{json.dumps(instance_data, ensure_ascii=False, indent=2)}
---
此邮件由爱特工作室物品管理及财务管理系统自动发送
"""
# 发送邮件到所有启用的通知邮箱
for email in notification_emails:
if email.strip(): # 确保邮箱不为空
try:
send_mail(
subject=subject,
message=message,
from_email=settings.EMAIL_HOST_USER,
recipient_list=[email.strip()],
fail_silently=False,
)
logger.info(f"邮件通知发送成功到 {email}: {operation_type} {model_name}")
except Exception as e:
logger.error(f"发送邮件到 {email} 失败: {e}")
except Exception as e:
logger.error(f"发送邮件通知失败: {e}")
@staticmethod
def send_item_operation_notification(operation_type, item_instance, user_info=None):
"""发送物品操作通知"""
try:
instance_data = {
'id': getattr(item_instance, 'id', None),
'name': getattr(item_instance, 'name', ''),
'serial_number': getattr(item_instance, 'serial_number', ''),
'status': getattr(item_instance, 'status', ''),
'category': getattr(item_instance, 'category', ''),
'owner': getattr(item_instance, 'owner', ''),
'timestamp': str(getattr(item_instance, 'updated_at', '')),
}
EmailNotificationService.send_operation_notification(
operation_type, '物品', instance_data, user_info
)
except Exception as e:
logger.error(f"发送物品操作通知失败: {e}")
@staticmethod
def send_finance_operation_notification(operation_type, finance_instance, user_info=None):
"""发送财务记录操作通知"""
try:
instance_data = {
'id': getattr(finance_instance, 'id', None),
'amount': str(getattr(finance_instance, 'amount', '')),
'transaction_type': getattr(finance_instance, 'transaction_type', ''),
'description': getattr(finance_instance, 'description', ''),
'department': str(getattr(finance_instance, 'department', '')),
'category': str(getattr(finance_instance, 'category', '')),
'approver': getattr(finance_instance, 'approver', ''),
'timestamp': str(getattr(finance_instance, 'transaction_date', '')),
}
EmailNotificationService.send_operation_notification(
operation_type, '财务记录', instance_data, user_info
)
except Exception as e:
logger.error(f"发送财务记录操作通知失败: {e}")
@staticmethod
def update_notification_emails(emails_data):
"""
更新通知邮箱列表(使用数据库存储)
Args:
emails_data: 邮箱数据列表,格式:[{'email': 'xxx@xxx.com', 'is_enabled': True, 'description': '描述'}]
"""
try:
from .models import NotificationEmail
# 清空现有邮箱
NotificationEmail.objects.all().delete()
# 添加新的邮箱
for email_info in emails_data:
if isinstance(email_info, str):
# 兼容旧格式(纯邮箱字符串)
NotificationEmail.objects.create(
email=email_info.strip(),
is_enabled=True
)
elif isinstance(email_info, dict):
# 新格式(包含详细信息)
NotificationEmail.objects.create(
email=email_info.get('email', '').strip(),
is_enabled=email_info.get('is_enabled', True),
description=email_info.get('description', '')
)
logger.info(f"通知邮箱配置已更新: {len(emails_data)} 个邮箱")
return True
except Exception as e:
logger.error(f"更新通知邮箱配置失败: {e}")
return False
@staticmethod
def update_global_notification_setting(enabled):
"""
更新全局邮件通知开关
Args:
enabled: 是否启用邮件通知
"""
try:
from .models import NotificationSettings
settings = NotificationSettings.get_settings()
settings.email_notification_enabled = enabled
settings.save()
logger.info(f"全局邮件通知设置已更新: {enabled}")
return True
except Exception as e:
logger.error(f"更新全局邮件通知设置失败: {e}")
return False
@staticmethod
def get_all_notification_emails():
"""获取所有通知邮箱(包括禁用的)"""
try:
from .models import NotificationEmail
emails = NotificationEmail.objects.all().values(
'id', 'email', 'is_enabled', 'description', 'created_at', 'updated_at'
)
return list(emails)
except Exception as e:
logger.error(f"获取通知邮箱列表失败: {e}")
return []
@staticmethod
def toggle_email_status(email_id, is_enabled):
"""
切换指定邮箱的启用状态
Args:
email_id: 邮箱ID
is_enabled: 是否启用
"""
try:
from .models import NotificationEmail
email_obj = NotificationEmail.objects.get(id=email_id)
email_obj.is_enabled = is_enabled
email_obj.save()
logger.info(f"邮箱 {email_obj.email} 状态已更新: {is_enabled}")
return True
except NotificationEmail.DoesNotExist:
logger.error(f"邮箱ID {email_id} 不存在")
return False
except Exception as e:
logger.error(f"更新邮箱状态失败: {e}")
return False
+47
View File
@@ -0,0 +1,47 @@
from django.test import TestCase
from django.core import mail
from .models import NotificationEmail, NotificationSettings
from .services import EmailNotificationService
class EmailNotificationTestCase(TestCase):
def setUp(self):
"""设置测试数据"""
self.email1 = NotificationEmail.objects.create(
email="test1@example.com",
is_enabled=True,
description="测试邮箱1"
)
self.email2 = NotificationEmail.objects.create(
email="test2@example.com",
is_enabled=False,
description="测试邮箱2"
)
def test_get_notification_settings(self):
"""测试获取通知设置"""
settings = EmailNotificationService.get_notification_settings()
self.assertTrue(settings['email_enabled'])
self.assertIn("test1@example.com", settings['notification_emails'])
self.assertNotIn("test2@example.com", settings['notification_emails'])
def test_toggle_email_status(self):
"""测试切换邮箱状态"""
result = EmailNotificationService.toggle_email_status(self.email2.id, True)
self.assertTrue(result)
self.email2.refresh_from_db()
self.assertTrue(self.email2.is_enabled)
def test_update_notification_emails(self):
"""测试更新通知邮箱"""
emails_data = [
{'email': 'new1@example.com', 'is_enabled': True, 'description': '新邮箱1'},
{'email': 'new2@example.com', 'is_enabled': False, 'description': '新邮箱2'}
]
result = EmailNotificationService.update_notification_emails(emails_data)
self.assertTrue(result)
# 检查数据库中的邮箱数量
self.assertEqual(NotificationEmail.objects.count(), 2)
self.assertTrue(NotificationEmail.objects.filter(email='new1@example.com').exists())
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from . import views
app_name = 'email_notice'
urlpatterns = [
path('api/notification-settings/', views.notification_settings, name='notification_settings'),
path('api/toggle-email-status/', views.toggle_email_status, name='toggle_email_status'),
]
+118
View File
@@ -0,0 +1,118 @@
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from .services import EmailNotificationService
import json
import re
@csrf_exempt
@require_http_methods(["POST", "GET"])
def notification_settings(request):
"""通知设置API"""
if request.method == 'GET':
try:
settings_data = EmailNotificationService.get_notification_settings()
all_emails = EmailNotificationService.get_all_notification_emails()
return JsonResponse({
'success': True,
'data': {
'email_enabled': settings_data.get('email_enabled', False),
'notification_emails': settings_data.get('notification_emails', []),
'all_emails': all_emails
}
})
except Exception as e:
return JsonResponse({'success': False, 'error': str(e)}, status=500)
elif request.method == 'POST':
try:
data = json.loads(request.body)
emails_to_update = data.get('notification_emails')
email_enabled_status = data.get('email_enabled')
# 标志位,用于判断是否执行了任何更新操作
was_updated = False
# 1. 如果请求中包含 'notification_emails' 键,则处理邮箱列表
if emails_to_update is not None: # 允许 emails_to_update 为空列表 []
email_regex = re.compile(r'^[^\s@]+@[^\s@]+\.[^\s@]+$')
valid_emails_data = []
for email_info in emails_to_update:
# 兼容前端可能发送的字符串或对象格式
if isinstance(email_info, str):
if email_info.strip() and email_regex.match(email_info.strip()):
valid_emails_data.append({
'email': email_info.strip(),
'is_enabled': True,
'description': ''
})
elif isinstance(email_info, dict):
email = email_info.get('email', '').strip()
if email and email_regex.match(email):
valid_emails_data.append({
'email': email,
'is_enabled': email_info.get('is_enabled', True),
'description': email_info.get('description', '')
})
# 调用服务层更新邮箱,服务层需要能处理空列表
EmailNotificationService.update_notification_emails(valid_emails_data)
was_updated = True
if email_enabled_status is not None:
EmailNotificationService.update_global_notification_setting(email_enabled_status)
was_updated = True
# 执行更新成功,返回成功
if was_updated:
return JsonResponse({
'success': True,
'message': '通知设置已更新',
# 返回最新的数据状态
'data': {
'email_enabled': EmailNotificationService.get_notification_settings().get('email_enabled', False),
'all_emails': EmailNotificationService.get_all_notification_emails()
}
})
else:
# 如果请求体为空或不包含任何有效键,则返回错误
return JsonResponse({'success': False, 'error': '未提供任何有效的更新数据'}, status=400)
except json.JSONDecodeError:
return JsonResponse({'success': False, 'error': '无效的JSON数据'}, status=400)
except Exception as e:
return JsonResponse({'success': False, 'error': str(e)}, status=500)
return JsonResponse({'success': False, 'error': '不支持的请求方法'}, status=405)
@csrf_exempt
@require_http_methods(["POST"])
def toggle_email_status(request):
"""切换邮箱启用状态API"""
try:
data = json.loads(request.body)
email_id = data.get('email_id')
is_enabled = data.get('is_enabled', True)
if not email_id:
return JsonResponse({'success': False, 'error': '缺少邮箱ID'}, status=400)
success = EmailNotificationService.toggle_email_status(email_id, is_enabled)
if success:
return JsonResponse({
'success': True,
'message': f'邮箱状态已更新为{"启用" if is_enabled else "禁用"}',
'data': {
'all_emails': EmailNotificationService.get_all_notification_emails()
}
})
else:
return JsonResponse({'success': False, 'error': '更新邮箱状态失败'}, status=500)
except json.JSONDecodeError:
return JsonResponse({'success': False, 'error': '无效的JSON数据'}, status=400)
except Exception as e:
return JsonResponse({'success': False, 'error': str(e)}, status=500)