Both were this app doing an identity provider's work. Sign-in and sign-up happen at sso.pedshub.com now: it takes the address, sends the code, checks it, and knows about second factors — none of which belongs here, and two of which were never done here at all. Gone: services/invites.py, services/login_codes.py, routers/login_code.py, the two models, the three admin invite routes, the invite_only flag and its switch, the invite field on both sign-up forms, and the code half of the sign-in page — which was the primary way in and is now a button that says "Sign in with PedsHub SSO". The password form stays for a site with no provider configured. Migration r7b8c9d0e1f2 drops invite_codes (three spent rows) and login_codes (empty). The dump beside it has both. 585 tests, and the contract snapshot is 320 routes — five fewer, all five named in the diff so the removal is reviewable rather than discovered later by a client. Also: "Make a deck" in the signed-in menu and on the landing page, going to the scribe's My Resources at app.pedshub.com/#resources. Same sign-in on both sides; the arrow says it leaves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
102 lines
3.9 KiB
Python
102 lines
3.9 KiB
Python
"""Site-wide switches an administrator sets once.
|
|
|
|
They live in Redis because they are configuration rather than records — but a
|
|
site must keep working when Redis does not, so every read falls back to the
|
|
default rather than raising. The defaults are deliberately the permissive ones
|
|
for features that already existed and the restrictive one for the gate that
|
|
protects sign-up: losing Redis should not silently open registration.
|
|
"""
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
#: name -> default. Anything not listed here cannot be set.
|
|
FLAGS: dict[str, bool] = {
|
|
#: Whether anybody new may join at all. Stored under the same key the
|
|
#: registration route and the admin settings page have always used; it was
|
|
#: simply not declared here, so the one helper that reads these flags
|
|
#: refused it and every caller wrote its own Redis lookup instead.
|
|
"registration_enabled": True,
|
|
#: Whether a learner may create a public share link for a session.
|
|
"sharing_enabled": True,
|
|
#: Whether the only way in is the identity provider. Read here as well as
|
|
#: in the SSO routes because it governs every email-based way of signing
|
|
#: in, link included, and each of those had been reading the key itself.
|
|
"sso_only": False,
|
|
#: Whether the AI tutor may be opened while a session is being sat. It is
|
|
#: only ever offered in study mode — the tutor is given the correct answer
|
|
#: and told it may reveal it, so during an exam it would simply hand it
|
|
#: over. This switch decides whether even study mode gets it.
|
|
"tutor_in_quiz": True,
|
|
#: Whether an AI draft may be grounded in the indexed clinical library.
|
|
#: Off until somebody points the site at a library and turns it on.
|
|
"clinical_library_enabled": False,
|
|
#: Whether an AI draft may search PubMed for published literature to cite.
|
|
"pubmed_enabled": False,
|
|
}
|
|
|
|
#: Settings that are text rather than a switch: an address, a key, an email.
|
|
#: name -> default. Anything not listed here cannot be set, for the same reason
|
|
#: the flags cannot: a typo should fail loudly rather than write a key nothing
|
|
#: will ever read.
|
|
VALUES: dict[str, str] = {
|
|
#: Where the clinical library answers. Blank means there is not one.
|
|
"clinical_mcp_url": "",
|
|
#: NCBI raises the rate limit for a caller who identifies themselves. Both
|
|
#: optional: E-utilities works without either, more slowly.
|
|
"pubmed_api_key": "",
|
|
"pubmed_contact_email": "",
|
|
}
|
|
|
|
|
|
def _client():
|
|
import redis as redis_lib
|
|
|
|
from app.config import settings
|
|
return redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
|
|
|
|
|
def get_flag(name: str, default: bool | None = None) -> bool:
|
|
if name not in FLAGS:
|
|
raise KeyError(name)
|
|
default = FLAGS[name] if default is None else default
|
|
try:
|
|
value = _client().get(f"settings:{name}")
|
|
except Exception:
|
|
logger.warning("Redis unavailable reading %s; using the default", name, exc_info=True)
|
|
return default
|
|
if value is None:
|
|
return default
|
|
return value == "true"
|
|
|
|
|
|
def set_flag(name: str, value: bool) -> None:
|
|
if name not in FLAGS:
|
|
raise KeyError(name)
|
|
_client().set(f"settings:{name}", "true" if value else "false")
|
|
|
|
|
|
def all_flags() -> dict[str, bool]:
|
|
return {name: get_flag(name) for name in FLAGS}
|
|
|
|
|
|
def get_value(name: str) -> str:
|
|
if name not in VALUES:
|
|
raise KeyError(name)
|
|
default = VALUES[name]
|
|
try:
|
|
value = _client().get(f"settings:{name}")
|
|
except Exception:
|
|
logger.warning("Redis unavailable reading %s; using the default", name, exc_info=True)
|
|
return default
|
|
return default if value is None else value
|
|
|
|
|
|
def set_value(name: str, value: str) -> None:
|
|
if name not in VALUES:
|
|
raise KeyError(name)
|
|
_client().set(f"settings:{name}", (value or "").strip())
|
|
|
|
|
|
def all_values() -> dict[str, str]:
|
|
return {name: get_value(name) for name in VALUES}
|