diff --git a/backend/app/main.py b/backend/app/main.py index 877e728..1244dfa 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,7 +13,7 @@ from app.database import engine, Base, SessionLocal from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote, exams from app.routers import access from app.routers import feedback -from app.routers import study_tools, uploads, articles, share, collections, study_plans, media, search, ai_mode, drafts +from app.routers import study_tools, uploads, articles, share, collections, study_plans, media, search, ai_mode, drafts, public from app.utils.auth import get_password_hash @@ -613,6 +613,8 @@ app.add_middleware(RequestLoggingMiddleware) app.include_router(uploads.router) app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) +# Counts the landing page states about itself. No auth: a stranger reads it. +app.include_router(public.router, prefix="/api/public", tags=["public"]) app.include_router(articles.router, prefix="/api/articles", tags=["articles"]) app.include_router(access.router, prefix="/api/access", tags=["access"]) app.include_router(feedback.router, prefix="/api/feedback", tags=["feedback"]) diff --git a/backend/app/routers/public.py b/backend/app/routers/public.py new file mode 100644 index 0000000..50b35b5 --- /dev/null +++ b/backend/app/routers/public.py @@ -0,0 +1,43 @@ +"""The few facts the site may state about itself before anybody signs in. + +Counts only, and only of published material. No titles, no ids, nothing that +could be walked to enumerate the bank — a landing page needs to say how much +there is, not what it is. + +Unauthenticated on purpose: it is read by the page a stranger lands on. +""" +from fastapi import APIRouter, Depends +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.article import Article +from app.models.exam import Exam +from app.models.question import Question +from app.models.question_category import QuestionCategory + +router = APIRouter() + + +@router.get("/stats") +def public_stats(db: Session = Depends(get_db)): + """What the landing page says there is, taken from what there is. + + A number written into the markup is a number that goes stale the week + after it is written, and nobody notices because nothing breaks. + """ + questions = db.query(func.count(Question.id)).filter( + Question.deleted_at.is_(None)).scalar() or 0 + topics = db.query(func.count(QuestionCategory.id)).scalar() or 0 + systems = db.query(func.count(func.distinct(QuestionCategory.system_id))).filter( + QuestionCategory.system_id.isnot(None)).scalar() or 0 + articles = db.query(func.count(Article.id)).filter( + Article.status == "published").scalar() or 0 + exams = db.query(func.count(Exam.id)).filter(Exam.is_active == 1).scalar() or 0 + return { + "questions": questions, + "topics": topics, + "systems": systems, + "articles": articles, + "exams": exams, + } diff --git a/backend/tests/test_study_tools.py b/backend/tests/test_study_tools.py index c1cfbed..4361d82 100644 --- a/backend/tests/test_study_tools.py +++ b/backend/tests/test_study_tools.py @@ -680,3 +680,53 @@ class SystemAxisTests(CompletionTests): # Null means nobody has said yet, and the grouping says nothing rather # than guessing a system from the shelf. self.assertEqual(self.systems().keys_for(2, 2), set()) + + +class PublicStatsTests(unittest.TestCase): + """The few facts the site may state about itself before anybody signs in. + + Counts only, and only of published material: a landing page needs to say + how much there is, not what it is. + """ + + def setUp(self): + self.bank = fixtures.BuilderTests() + self.bank.setUp() + self.client = self.bank.client + from app.routers import public + self.client.app.include_router(public.router, prefix='/public') + + def tearDown(self): + self.bank.tearDown() + + def test_it_counts_what_is_there_rather_than_what_was_written_down(self): + response = self.client.get('/public/stats') + self.assertEqual(response.status_code, 200, response.text) + data = response.json() + # Six questions in the fixture bank, none deleted. + self.assertEqual(data['questions'], 6) + self.assertEqual(data['topics'], 4) + self.assertEqual(set(data), {'questions', 'topics', 'systems', 'articles', 'exams'}) + + def test_a_deleted_question_is_not_counted(self): + from datetime import datetime + + from app.models.question import Question + self.bank.db.get(Question, 1).deleted_at = datetime.utcnow() + self.bank.db.commit() + self.assertEqual(self.client.get('/public/stats').json()['questions'], 5) + + def test_an_unpublished_article_is_not_counted(self): + from app.models.article import Article + self.bank.db.add_all([ + Article(title='Out', slug='out', content='…', status='draft', user_id=1), + Article(title='In', slug='in', content='…', status='published', user_id=1), + ]) + self.bank.db.commit() + # A stranger is told what is readable, not what is in progress. + self.assertEqual(self.client.get('/public/stats').json()['articles'], 1) + + def test_it_says_nothing_a_stranger_could_walk(self): + data = self.client.get('/public/stats').json() + # Numbers only. No titles, no ids, nothing to enumerate the bank with. + self.assertTrue(all(isinstance(v, int) for v in data.values())) diff --git a/frontend/src/components/ChooseObjective.jsx b/frontend/src/components/ChooseObjective.jsx index 57ae83d..0bb633d 100644 --- a/frontend/src/components/ChooseObjective.jsx +++ b/frontend/src/components/ChooseObjective.jsx @@ -2,8 +2,6 @@ import { useEffect, useState } from 'react' import api from '../api/client' import './ChooseObjective.css' -const DEFERRED = 'pedshub.objectiveDeferred' - /** * Asked once, of anybody who has not answered it. * @@ -13,21 +11,18 @@ const DEFERRED = 'pedshub.objectiveDeferred' * objective quietly means "the entire bank". That is a reasonable default and * a terrible thing to arrive at by accident. * - * So it is a question rather than a setting to discover. It can be declined — - * "everything" is a real answer, and trapping somebody behind a modal because - * a list failed to load would be worse than the gap it closes — but declining - * is a choice made, which is the whole point. + * So it is asked, and it is not optional: there is no way past it but to + * answer. What is guarded against instead is asking a question that cannot be + * answered — if the list of objectives fails to load, or there are none to + * choose from, nothing is shown at all. A modal with no options in it is not a + * question, it is a locked door. */ export default function ChooseObjective() { const [exams, setExams] = useState(null) const [busy, setBusy] = useState(false) const [error, setError] = useState('') - const [dismissed, setDismissed] = useState(() => { - try { return sessionStorage.getItem(DEFERRED) === 'true' } catch { return false } - }) useEffect(() => { - if (dismissed) return undefined let live = true api.get('/exams/') .then(res => { @@ -38,7 +33,7 @@ export default function ChooseObjective() { }) .catch(() => { if (live) setExams([]) }) return () => { live = false } - }, [dismissed]) + }, []) const choose = async (examId) => { setBusy(true); setError('') @@ -49,17 +44,14 @@ export default function ChooseObjective() { // is worse than a second of waiting. window.location.reload() } catch { - setError('Could not save that. Try again, or pick it later from the bar at the top.') + setError('Could not save that. Try again.') setBusy(false) } } - const later = () => { - try { sessionStorage.setItem(DEFERRED, 'true') } catch { /* private browsing */ } - setDismissed(true) - } - - if (dismissed || exams === null || exams.length === 0) return null + // Nothing to ask, or nothing to answer with. Either way there is no + // question here, and a modal with no options in it is a locked door. + if (exams === null || exams.length === 0) return null return (
It decides which questions you are shown, how much each topic is - worth, and what your readiness is measured against. You can change it - any time from the bar at the top. + worth, and what your readiness is measured against, so it is the one + thing worth settling before anything else. You can change it any time + from the bar at the top.
{error &&{error}
} @@ -88,9 +81,6 @@ export default function ChooseObjective() { ))} -