feat: the objective is not optional, and the site can count itself
Choosing what you are studying for has no way past it now but to answer. It decides which questions exist, how relevance is weighted and what readiness measures against, so an account that never answered it was being shown the whole bank by accident rather than by choice. What is guarded instead is asking a question that cannot be answered: if the list of objectives fails to load, or there are none, nothing is shown at all. A modal with no options in it is not a question, it is a locked door. GET /api/public/stats, unauthenticated, so the landing page can state what there is rather than what someone typed into the markup months ago — a number written into a page goes stale the week after and nothing breaks to say so. Counts only, and only of published material: how much there is, never what it is, so there is nothing here to walk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
16aed6b6b0
commit
77c09db057
5 changed files with 128 additions and 34 deletions
|
|
@ -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"])
|
||||
|
|
|
|||
43
backend/app/routers/public.py
Normal file
43
backend/app/routers/public.py
Normal file
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="co-overlay" role="dialog" aria-modal="true" aria-labelledby="co-heading">
|
||||
|
|
@ -67,8 +59,9 @@ export default function ChooseObjective() {
|
|||
<h2 id="co-heading">What are you studying for?</h2>
|
||||
<p className="co-lead">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{error && <p className="co-error" role="alert">{error}</p>}
|
||||
|
|
@ -88,9 +81,6 @@ export default function ChooseObjective() {
|
|||
))}
|
||||
</ul>
|
||||
|
||||
<button type="button" className="co-later" disabled={busy} onClick={later}>
|
||||
Show me everything for now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import ChooseObjective from './ChooseObjective'
|
||||
|
|
@ -45,20 +45,29 @@ it('saves the choice', async () => {
|
|||
await waitFor(() => expect(api.put).toHaveBeenCalledWith('/exams/active', { exam_id: 1 }))
|
||||
})
|
||||
|
||||
it('lets somebody decline, and does not ask again this session', async () => {
|
||||
it('offers no way past it but to answer', async () => {
|
||||
answer(null)
|
||||
const { unmount } = render(<ChooseObjective />)
|
||||
// "Everything" is a real answer, and trapping somebody behind a modal is
|
||||
// worse than the gap it closes.
|
||||
await userEvent.click(await screen.findByRole('button', { name: /Show me everything/ }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
render(<ChooseObjective />)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
// Every button in it is an objective. There is no decline, no close, and no
|
||||
// deferral: it decides what the whole site is showing you.
|
||||
const buttons = within(dialog).getAllByRole('button')
|
||||
expect(buttons).toHaveLength(EXAMS.length)
|
||||
await userEvent.keyboard('{Escape}')
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('asks nothing when there is nothing to choose from', async () => {
|
||||
api.get.mockResolvedValue({ data: { active_exam_id: null, exams: [] } })
|
||||
render(<ChooseObjective />)
|
||||
// A modal with no options in it is not a question, it is a locked door.
|
||||
await waitFor(() => expect(api.get).toHaveBeenCalled())
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stays out of the way when the list cannot be fetched', async () => {
|
||||
// Forcing a choice is only fair while there is one to make; a failed fetch
|
||||
// must not lock somebody out of their own account.
|
||||
api.get.mockRejectedValue(new Error('down'))
|
||||
render(<ChooseObjective />)
|
||||
await waitFor(() => expect(api.get).toHaveBeenCalled())
|
||||
|
|
|
|||
Loading…
Reference in a new issue