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

View file

@ -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}

View file

@ -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: