pdf-quiz-generator/backend/app/services/site_settings.py
Daniel cf2f42975e
Some checks failed
Tests / backend (push) Failing after 8s
Tests / frontend (push) Successful in 33s
Tests / e2e (push) Failing after 33s
feat: no invite codes and no email sign-in codes — that is the provider's job
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
2026-09-13 13:56:48 +02:00

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}