feat: 添加PDF报告邮件发送功能

- 在core/tasks.py中添加celery_send_pdf_report_email任务,用于生成PDF报告并发送邮件
- 在test_redis_celery.py中添加对应的测试用例test_celery_redis_pdf_email
- 更新main函数中的测试计数和结果输出逻辑
This commit is contained in:
2026-01-19 21:36:23 +08:00
parent e2f389a325
commit 83bbcd8ff7
2 changed files with 273 additions and 10 deletions

View File

@@ -5,6 +5,9 @@ from django.core.mail.backends.smtp import EmailBackend
from django.utils import timezone from django.utils import timezone
from datetime import timedelta from datetime import timedelta
import traceback import traceback
import os
import re
from django.db.models import F
@shared_task(bind=True, max_retries=3, default_retry_delay=60) @shared_task(bind=True, max_retries=3, default_retry_delay=60)
@@ -327,3 +330,196 @@ def celery_send_html_report_email(self, include_attachment=False):
'error': error_msg, 'error': error_msg,
'timestamp': timezone.now().isoformat() 'timestamp': timezone.now().isoformat()
} }
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def celery_send_pdf_report_email(self):
"""
Celery异步生成PDF报告并发送邮件
1. 检查WeasyPrint是否可用
2. 生成PDF报告
3. 发送包含PDF附件的邮件
"""
task_id = self.request.id if hasattr(self, 'request') else 'unknown'
logger.info(f"[任务 {task_id}] 开始执行PDF报告邮件发送任务")
try:
# 检查WeasyPrint是否可用
weasyprint_available = False
try:
from weasyprint import HTML
weasyprint_available = True
except ImportError:
logger.error("[任务 {task_id}] WeasyPrint库无法导入PDF功能将不可用")
raise ValueError("WeasyPrint库无法导入PDF功能将不可用")
if not weasyprint_available:
raise ValueError("WeasyPrint库不可用无法生成PDF报告")
# 从数据库获取邮件配置
from core.models import SystemConfig, ReadingRecord, InsightRecord, TodayPlan, FamilyTask
from django.conf import settings
from django.template.loader import render_to_string
config = SystemConfig.get_config()
# 优先使用sender_email其次使用smtp_username作为发件人
from_email = config.sender_email or config.smtp_username
if not from_email:
raise ValueError("未配置发件邮箱")
# 验证发件人邮箱格式
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(email_pattern, from_email):
raise ValueError(f"发件人邮箱格式不正确: {from_email}")
to_email = config.recipient_email or from_email
# 验证收件人邮箱格式
if not re.match(email_pattern, to_email):
raise ValueError(f"收件人邮箱格式不正确: {to_email}")
recipient_list = [to_email] if isinstance(to_email, str) else to_email
# 获取SMTP配置
host = config.smtp_server or 'localhost'
port = config.smtp_port or 587
username = config.smtp_username or ''
password = config.smtp_password or ''
use_tls = True # 默认使用TLS
# 准备报告数据
today = timezone.now().date()
yesterday = today - timedelta(days=1)
# 获取昨日记录
yesterday_reading = ReadingRecord.objects.filter(date=yesterday)
yesterday_insight = InsightRecord.objects.filter(date=yesterday)
# 获取今日计划
today_plan = TodayPlan.objects.filter(date=today)
# 获取家庭事项统计
family_task_stats = FamilyTask.objects.values('type').annotate(count=FamilyTask.objects.values('id').filter(type=F('type')).count())
# 准备上下文数据
context = {
'today': today,
'yesterday': yesterday,
'yesterday_reading': yesterday_reading,
'yesterday_insight': yesterday_insight,
'today_plan': today_plan,
'family_task_stats': family_task_stats,
}
# 渲染HTML模板
html_string = render_to_string('core/report_pdf.html', context)
# 生成PDF
today_str = today.strftime('%Y-%m-%d')
pdf_file = f"report_{today_str}.pdf"
pdf_path = os.path.join(settings.REPORTS_ROOT, pdf_file)
# 确保报告目录存在
os.makedirs(settings.REPORTS_ROOT, exist_ok=True)
# 使用WeasyPrint生成PDF
from weasyprint import HTML
HTML(string=html_string).write_pdf(pdf_path)
logger.info(f"[任务 {task_id}] PDF报告生成成功: {pdf_path}")
# 创建邮件后端
backend = EmailBackend(
host=host,
port=port,
username=username,
password=password,
use_tls=use_tls,
fail_silently=False
)
# 创建邮件
subject = f"[Celery] 家庭日报 {today_str} - PDF报告"
body = f"""
这是一封包含PDF报告的邮件。
报告日期: {today_str}
发送时间: {timezone.now().strftime('%Y-%m-%d %H:%M:%S')}
任务ID: {task_id}
报告包含以下内容:
- 昨日阅读记录 ({yesterday_reading.count()} 条)
- 昨日感悟记录 ({yesterday_insight.count()} 条)
- 今日计划 ({today_plan.count()} 项)
- 家庭事项统计
请查看附件获取完整报告。
---
家庭日报系统
自动发送Celery异步任务
"""
email = EmailMessage(
subject=subject,
body=body,
from_email=from_email,
to=recipient_list,
connection=backend
)
email.content_subtype = 'plain'
email.encoding = 'utf-8'
# 添加PDF附件
with open(pdf_path, 'rb') as f:
email.attach(pdf_file, f.read(), 'application/pdf')
logger.info(f"[任务 {task_id}] 正在发送包含PDF附件的邮件...")
# 发送邮件
sent_count = email.send(fail_silently=False)
if sent_count > 0:
logger.success(f"[任务 {task_id}] 包含PDF附件的邮件发送成功")
# 清理生成的PDF文件
if os.path.exists(pdf_path):
os.remove(pdf_path)
logger.info(f"[任务 {task_id}] 已清理生成的PDF文件: {pdf_path}")
return {
'status': 'success',
'task_id': task_id,
'type': 'pdf_report',
'sent_to': len(recipient_list),
'pdf_file': pdf_file,
'timestamp': timezone.now().isoformat()
}
else:
raise Exception("邮件发送返回0")
except Exception as e:
error_msg = str(e)
error_traceback = traceback.format_exc()
logger.error(f"[任务 {task_id}] PDF报告邮件发送失败: {error_msg}")
logger.error(f"[任务 {task_id}] 错误详情:\n{error_traceback}")
# 检查是否应该重试
if hasattr(self, 'request') and self.request.retries < self.max_retries:
logger.info(f"[任务 {task_id}] 准备第 {self.request.retries + 1} 次重试...")
# 重试延迟指数增长
retry_delay = 60 * (2 ** self.request.retries)
logger.info(f"[任务 {task_id}] {retry_delay}秒后进行重试")
raise self.retry(exc=e, countdown=retry_delay)
else:
logger.error(f"[任务 {task_id}] 已达到最大重试次数 {self.max_retries},放弃重试")
# 返回错误信息而不是抛出异常
return {
'status': 'failed',
'task_id': task_id,
'type': 'pdf_report',
'error': error_msg,
'retries': self.request.retries if hasattr(self, 'request') else 0,
'timestamp': timezone.now().isoformat()
}

View File

@@ -353,6 +353,65 @@ def test_celery_redis_email():
return False return False
def test_celery_redis_pdf_email():
"""测试通过Celery和Redis发送包含PDF附件的邮件功能"""
logger.info("开始测试通过Celery和Redis发送包含PDF附件的邮件...")
try:
from core.tasks import celery_send_pdf_report_email
# 发送PDF报告邮件任务
logger.info("发送Celery PDF报告邮件测试任务到Redis队列...")
task = celery_send_pdf_report_email.delay()
logger.info(f"PDF报告邮件测试任务已发送任务ID: {task.id}")
# 等待任务完成
logger.info("等待任务执行最多60秒...")
result = task.get(timeout=60)
logger.info(f"PDF报告邮件任务执行结果: {result}")
# 检查结果状态
if result.get('status') == 'success':
logger.success("通过Celery和Redis发送包含PDF附件的邮件测试成功")
return True
else:
error_msg = result.get('error', '未知错误')
logger.error(f"包含PDF附件的邮件发送失败: {error_msg}")
# 提供具体的解决方案
if "WeasyPrint" in error_msg:
logger.error("📄 WeasyPrint相关错误可能的原因:")
logger.error("1. WeasyPrint库未安装")
logger.error("2. WeasyPrint依赖项缺失")
logger.error("3. 权限问题导致无法生成PDF")
logger.error("解决方案: 运行 'pip install weasyprint' 安装WeasyPrint库")
return False
except Exception as e:
error_msg = str(e)
logger.error(f"通过Celery和Redis发送包含PDF附件的邮件测试失败: {error_msg}")
# 提供具体的解决方案
if "No such file or directory" in error_msg:
logger.error("📁 文件路径错误,可能的原因:")
logger.error("1. 报告目录不存在")
logger.error("2. 权限问题导致无法访问目录")
elif "Connection refused" in error_msg:
logger.error("🔌 连接被拒绝,可能的原因:")
logger.error("1. Celery worker未运行")
logger.error("2. Redis服务未运行")
logger.error("3. 防火墙阻止连接")
else:
logger.error("请检查:")
logger.error("1. Celery worker是否运行: sudo supervisorctl status celery_worker")
logger.error("2. Redis服务状态: sudo systemctl status redis-server")
logger.error("3. 系统配置中的邮件设置是否正确")
logger.error("4. SMTP服务器配置是否正确")
logger.error("5. WeasyPrint库是否已正确安装")
return False
def check_logs_config(): def check_logs_config():
"""检查Gunicorn、Celery、Redis的日志是否写入同一个文件""" """检查Gunicorn、Celery、Redis的日志是否写入同一个文件"""
logger.info("开始检查日志配置...") logger.info("开始检查日志配置...")
@@ -430,7 +489,7 @@ def main():
logger.info("=" * 50) logger.info("=" * 50)
tests_passed = 0 tests_passed = 0
total_tests = 5 total_tests = 6
# 测试1: Redis连接 # 测试1: Redis连接
logger.info("\n[测试1] Redis连接测试") logger.info("\n[测试1] Redis连接测试")
@@ -454,8 +513,13 @@ def main():
if test_celery_redis_email(): if test_celery_redis_email():
tests_passed += 1 tests_passed += 1
# 测试5: 检查日志配置 # 测试5: 通过Celery和Redis发送包含PDF附件的邮件
logger.info("\n[测试5] 检查日志配置") logger.info("\n[测试5] 通过Celery和Redis发送包含PDF附件的邮件测试")
if test_celery_redis_pdf_email():
tests_passed += 1
# 测试6: 检查日志配置
logger.info("\n[测试6] 检查日志配置")
if check_logs_config(): if check_logs_config():
tests_passed += 1 tests_passed += 1
@@ -471,19 +535,21 @@ def main():
logger.info("2. ✅ Celery可以连接到Redis") logger.info("2. ✅ Celery可以连接到Redis")
logger.info("3. ✅ Redis性能满足要求") logger.info("3. ✅ Redis性能满足要求")
logger.info("4. ✅ 可以通过Celery和Redis发送邮件") logger.info("4. ✅ 可以通过Celery和Redis发送邮件")
logger.info("5. ✅ 日志配置检查完成") logger.info("5. ✅ 可以通过Celery和Redis发送包含PDF附件的邮件")
logger.info("6. 建议配置Redis持久化和备份") logger.info("6. ✅ 日志配置检查完成")
logger.info("7. 建议监控Redis内存使用情况") logger.info("7. 建议配置Redis持久化和备份")
logger.info("8. 确保Gunicorn、Celery、Redis日志写入同一个文件") logger.info("8. 建议监控Redis内存使用情况")
logger.info("9. 确保Gunicorn、Celery、Redis日志写入同一个文件")
return 0 return 0
elif tests_passed >= 3: elif tests_passed >= 4:
logger.warning("部分测试通过,生产环境基本可用。") logger.warning("部分测试通过,生产环境基本可用。")
logger.info("\n需要检查:") logger.info("\n需要检查:")
logger.info("1. 确保Redis服务正常运行") logger.info("1. 确保Redis服务正常运行")
logger.info("2. 检查Celery worker配置") logger.info("2. 检查Celery worker配置")
logger.info("3. 参考README中的故障排除指南") logger.info("3. 参考README中的故障排除指南")
logger.info("4. 检查邮件配置是否正确") logger.info("4. 检查邮件配置是否正确")
logger.info("5. 检查日志配置是否符合要求") logger.info("5. 检查WeasyPrint库是否已正确安装")
logger.info("6. 检查日志配置是否符合要求")
return 1 return 1
else: else:
logger.error("多数测试失败,生产环境可能无法正常工作。") logger.error("多数测试失败,生产环境可能无法正常工作。")
@@ -493,7 +559,8 @@ def main():
logger.info("3. ❌ 检查Django settings.py中的Celery配置") logger.info("3. ❌ 检查Django settings.py中的Celery配置")
logger.info("4. ❌ 检查Celery worker是否运行") logger.info("4. ❌ 检查Celery worker是否运行")
logger.info("5. ❌ 检查系统配置中的邮件设置") logger.info("5. ❌ 检查系统配置中的邮件设置")
logger.info("6. 参考README中的Redis部署章节重新配置") logger.info("6. ❌ 检查WeasyPrint库是否已正确安装")
logger.info("7. ❌ 参考README中的Redis部署章节重新配置")
return 1 return 1
if __name__ == "__main__": if __name__ == "__main__":