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 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")
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue