pdf-quiz-generator/backend/app/utils/scheduler.py
Daniel b39e09f393 Persist reminders_disabled in Postgres and harden reminder scheduler
- 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.
2026-04-14 04:31:03 +02:00

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()