- Add reminders_disabled boolean column to users (DB source of truth). - Scheduler reads the DB column directly; Redis no longer used for opt-out checks. - Scheduler now deactivates reminders when a user has no completed attempts left for a quiz (e.g., after deleting their attempts). - Settings API: GET returns DB value for reminders_disabled; PUT persists that key to DB and keeps the rest of the blob in Redis. Rollback point for Alembic wiring.
98 lines
3.2 KiB
Python
98 lines
3.2 KiB
Python
import asyncio
|
|
import logging
|
|
from datetime import datetime
|
|
|
|
from apscheduler.schedulers.background import BackgroundScheduler
|
|
|
|
from app.database import SessionLocal
|
|
from app.models.reminder import ReminderSchedule
|
|
from app.models.user import User
|
|
from app.models.quiz import Quiz
|
|
from app.models.attempt import QuizAttempt
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
scheduler = BackgroundScheduler()
|
|
|
|
|
|
def check_and_send_reminders():
|
|
"""Check for due reminders and send emails."""
|
|
db = SessionLocal()
|
|
try:
|
|
due_reminders = db.query(ReminderSchedule).filter(
|
|
ReminderSchedule.is_active == True,
|
|
ReminderSchedule.next_reminder_at <= datetime.utcnow(),
|
|
).all()
|
|
|
|
if not due_reminders:
|
|
return
|
|
|
|
logger.info(f"Found {len(due_reminders)} due reminders")
|
|
|
|
for reminder in due_reminders:
|
|
user = db.query(User).filter(User.id == reminder.user_id).first()
|
|
quiz = db.query(Quiz).filter(Quiz.id == reminder.quiz_id).first()
|
|
|
|
if not user or not quiz or quiz.deleted_at or quiz.course_id:
|
|
reminder.is_active = False
|
|
continue
|
|
|
|
# Deactivate if the user has no completed attempts left for this quiz
|
|
# (e.g., they deleted their attempts — spaced repetition no longer applies).
|
|
has_completed_attempt = db.query(QuizAttempt.id).filter(
|
|
QuizAttempt.user_id == user.id,
|
|
QuizAttempt.quiz_id == quiz.id,
|
|
QuizAttempt.completed_at.isnot(None),
|
|
).first() is not None
|
|
if not has_completed_attempt:
|
|
reminder.is_active = False
|
|
continue
|
|
|
|
# Respect the user's opt-out preference (canonical source: Postgres).
|
|
if user.reminders_disabled:
|
|
continue
|
|
|
|
# Send email asynchronously
|
|
try:
|
|
from app.services.email_service import send_reminder_email
|
|
loop = asyncio.new_event_loop()
|
|
loop.run_until_complete(
|
|
send_reminder_email(
|
|
email=user.email,
|
|
user_name=user.name,
|
|
quiz_title=quiz.title,
|
|
score=reminder.performance_score,
|
|
next_date=reminder.next_reminder_at.strftime("%Y-%m-%d"),
|
|
)
|
|
)
|
|
loop.close()
|
|
except Exception as e:
|
|
logger.error(f"Failed to send reminder {reminder.id}: {e}")
|
|
|
|
# Schedule next reminder
|
|
from datetime import timedelta
|
|
reminder.next_reminder_at = datetime.utcnow() + timedelta(days=reminder.interval_days)
|
|
|
|
db.commit()
|
|
except Exception as e:
|
|
logger.exception(f"Scheduler error: {e}")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def start_scheduler():
|
|
"""Start the APScheduler with daily reminder check."""
|
|
scheduler.add_job(
|
|
check_and_send_reminders,
|
|
"interval",
|
|
hours=24,
|
|
id="reminder_check",
|
|
replace_existing=True,
|
|
)
|
|
scheduler.start()
|
|
logger.info("Scheduler started — reminder check runs every 24 hours")
|
|
|
|
|
|
def stop_scheduler():
|
|
if scheduler.running:
|
|
scheduler.shutdown()
|