diff --git a/backend/alembic/versions/9bac7bf02e38_add_reminders_disabled_to_users.py b/backend/alembic/versions/9bac7bf02e38_add_reminders_disabled_to_users.py new file mode 100644 index 0000000..d1b67fd --- /dev/null +++ b/backend/alembic/versions/9bac7bf02e38_add_reminders_disabled_to_users.py @@ -0,0 +1,70 @@ +"""add reminders_disabled to users + +Revision ID: 9bac7bf02e38 +Revises: c3aafcd58735 +Create Date: 2026-04-14 03:30:00.000000 + +Adds a `reminders_disabled` BOOLEAN column to `users` so the opt-out preference +is persisted in Postgres instead of Redis (which was volatile and fail-opened +when unreachable). + +On upgrade, attempts a one-time Redis -> DB sync so existing opt-outs are +preserved. Silently skips if Redis is unavailable. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = '9bac7bf02e38' +down_revision: Union[str, None] = 'c3aafcd58735' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + 'users', + sa.Column( + 'reminders_disabled', + sa.Boolean(), + server_default=sa.text('false'), + nullable=False, + ), + ) + + # Best-effort: migrate existing opt-outs from Redis so users don't start + # receiving emails they had previously turned off. + try: + import os + import json + import redis + + redis_url = os.environ.get('REDIS_URL') + if not redis_url: + return + + r = redis.from_url(redis_url, decode_responses=True, socket_connect_timeout=3) + conn = op.get_bind() + for key in r.scan_iter('user_settings:*', count=100): + try: + raw = r.get(key) + if not raw: + continue + data = json.loads(raw) + if not data.get('reminders_disabled'): + continue + uid = int(key.split(':', 1)[1]) + conn.execute( + sa.text('UPDATE users SET reminders_disabled = TRUE WHERE id = :uid'), + {'uid': uid}, + ) + except Exception: + continue + except Exception: + pass + + +def downgrade() -> None: + op.drop_column('users', 'reminders_disabled') diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 81de4ae..8ad0955 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,7 +1,8 @@ from datetime import datetime -from sqlalchemy import Column, Integer, String, DateTime +from sqlalchemy import Column, Integer, String, DateTime, Boolean from sqlalchemy.orm import relationship +from sqlalchemy.sql import false as sa_false from app.database import Base @@ -15,6 +16,7 @@ class User(Base): name = Column(String, nullable=False) role = Column(String, default="user") # admin, moderator, user is_unthrottled = Column(Integer, default=0) # 1 = exempt from rate limits + reminders_disabled = Column(Boolean, default=False, nullable=False, server_default=sa_false()) created_at = Column(DateTime, default=datetime.utcnow) documents = relationship("PDFDocument", back_populates="user") diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 8292112..a368ceb 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -265,32 +265,50 @@ def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)): @router.get("/me/settings") -def get_user_settings(current_user: User = Depends(get_current_user)): - """Get user settings stored in Redis (Nextcloud config etc.).""" +def get_user_settings( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Get user settings. Most settings live in Redis (Nextcloud config etc.); + reminders_disabled is canonical in Postgres and overrides Redis.""" + data = {} try: import redis as redis_lib, json from app.config import settings as cfg r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True) - data = r.get(f"user_settings:{current_user.id}") - return json.loads(data) if data else {} + raw = r.get(f"user_settings:{current_user.id}") + if raw: + data = json.loads(raw) except Exception as e: import logging; logging.getLogger(__name__).warning(f"Failed to load user settings: {e}") - return {} + + # Canonical opt-out preference comes from the DB + data["reminders_disabled"] = bool(current_user.reminders_disabled) + return data @router.put("/me/settings") def save_user_settings( settings_data: dict, current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), ): - """Save user settings to Redis.""" + """Save user settings. reminders_disabled is persisted to Postgres; + other keys go to Redis.""" + # Persist opt-out preference to DB (canonical source for the scheduler) + if "reminders_disabled" in settings_data: + current_user.reminders_disabled = bool(settings_data.get("reminders_disabled")) + db.add(current_user) + db.commit() + + # Keep the full blob in Redis so other fields (Nextcloud config etc.) persist try: import redis as redis_lib, json from app.config import settings as cfg r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True) r.set(f"user_settings:{current_user.id}", json.dumps(settings_data)) except Exception as e: - import logging; logging.getLogger(__name__).warning(f"Failed to save user settings: {e}") + import logging; logging.getLogger(__name__).warning(f"Failed to save user settings to Redis: {e}") return {"saved": True} diff --git a/backend/app/utils/scheduler.py b/backend/app/utils/scheduler.py index f358d20..688e941 100644 --- a/backend/app/utils/scheduler.py +++ b/backend/app/utils/scheduler.py @@ -8,6 +8,7 @@ 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__) @@ -36,16 +37,20 @@ def check_and_send_reminders(): reminder.is_active = False continue - # Check if user has opted out of reminders - try: - import redis as redis_lib, json as _json - from app.config import settings as cfg - r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True, socket_connect_timeout=1) - user_settings = _json.loads(r.get(f"user_settings:{user.id}") or "{}") - if user_settings.get("reminders_disabled"): - continue - except Exception: - pass + # 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: