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:
parent
dc240c31d2
commit
b39e09f393
4 changed files with 113 additions and 18 deletions
|
|
@ -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')
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue