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.
This commit is contained in:
Daniel 2026-04-14 04:31:03 +02:00
parent dc240c31d2
commit b39e09f393
4 changed files with 113 additions and 18 deletions

View file

@ -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')

View file

@ -1,7 +1,8 @@
from datetime import datetime 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.orm import relationship
from sqlalchemy.sql import false as sa_false
from app.database import Base from app.database import Base
@ -15,6 +16,7 @@ class User(Base):
name = Column(String, nullable=False) name = Column(String, nullable=False)
role = Column(String, default="user") # admin, moderator, user role = Column(String, default="user") # admin, moderator, user
is_unthrottled = Column(Integer, default=0) # 1 = exempt from rate limits 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) created_at = Column(DateTime, default=datetime.utcnow)
documents = relationship("PDFDocument", back_populates="user") documents = relationship("PDFDocument", back_populates="user")

View file

@ -265,32 +265,50 @@ def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)):
@router.get("/me/settings") @router.get("/me/settings")
def get_user_settings(current_user: User = Depends(get_current_user)): def get_user_settings(
"""Get user settings stored in Redis (Nextcloud config etc.).""" 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: try:
import redis as redis_lib, json import redis as redis_lib, json
from app.config import settings as cfg from app.config import settings as cfg
r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True) r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True)
data = r.get(f"user_settings:{current_user.id}") raw = r.get(f"user_settings:{current_user.id}")
return json.loads(data) if data else {} if raw:
data = json.loads(raw)
except Exception as e: except Exception as e:
import logging; logging.getLogger(__name__).warning(f"Failed to load user settings: {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") @router.put("/me/settings")
def save_user_settings( def save_user_settings(
settings_data: dict, settings_data: dict,
current_user: User = Depends(get_current_user), 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: try:
import redis as redis_lib, json import redis as redis_lib, json
from app.config import settings as cfg from app.config import settings as cfg
r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True) r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True)
r.set(f"user_settings:{current_user.id}", json.dumps(settings_data)) r.set(f"user_settings:{current_user.id}", json.dumps(settings_data))
except Exception as e: 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} return {"saved": True}

View file

@ -8,6 +8,7 @@ from app.database import SessionLocal
from app.models.reminder import ReminderSchedule from app.models.reminder import ReminderSchedule
from app.models.user import User from app.models.user import User
from app.models.quiz import Quiz from app.models.quiz import Quiz
from app.models.attempt import QuizAttempt
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -36,16 +37,20 @@ def check_and_send_reminders():
reminder.is_active = False reminder.is_active = False
continue continue
# Check if user has opted out of reminders # Deactivate if the user has no completed attempts left for this quiz
try: # (e.g., they deleted their attempts — spaced repetition no longer applies).
import redis as redis_lib, json as _json has_completed_attempt = db.query(QuizAttempt.id).filter(
from app.config import settings as cfg QuizAttempt.user_id == user.id,
r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True, socket_connect_timeout=1) QuizAttempt.quiz_id == quiz.id,
user_settings = _json.loads(r.get(f"user_settings:{user.id}") or "{}") QuizAttempt.completed_at.isnot(None),
if user_settings.get("reminders_disabled"): ).first() is not None
continue if not has_completed_attempt:
except Exception: reminder.is_active = False
pass continue
# Respect the user's opt-out preference (canonical source: Postgres).
if user.reminders_disabled:
continue
# Send email asynchronously # Send email asynchronously
try: try: