refactor: remove per-question sharing
`Question.is_shared` defaulted to 1 and was only ever set by a route nothing called, so in practice it divided the bank into "everything" and "everything, plus your own private ones" — a distinction that cost every recommendation denominator a join and never changed an answer. Who may reach the bank is the site's own access rules; who may manage a question is the category grant tree. So the two predicates the whole bank was built on are now the same thing, and say what they actually mean: a question is out of reach if it has been deleted or belongs to a course. Nothing else. The column is dropped, the route that set it is gone, the bulk "share" action with it, and the Private tile and pill go from the question manager. The tests that turned on it have been rewritten rather than deleted, because the rule they were really about survives: revoking a question still revokes every session carrying it — by deleting it, which is the only revocation left. Several others named a category holding exactly two reachable questions and then answered two particular ids; that category holds four now, so they name the pair instead. A session's own sharing flag is untouched — that is a different thing, and it is still how a session is handed to somebody. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
1df520c95d
commit
5d59e00144
31 changed files with 209 additions and 166 deletions
|
|
@ -0,0 +1,29 @@
|
|||
"""Drop questions.is_shared.
|
||||
|
||||
Per-question sharing is gone. The column defaulted to 1 and was only ever set
|
||||
by a route nothing called, so in practice it divided the bank into "everything"
|
||||
and "everything, plus your own private ones" — a distinction that cost every
|
||||
recommendation denominator a join and never changed an answer. Who may see the
|
||||
bank is decided by the site's own access rules, and who may *manage* a question
|
||||
by the category grant tree.
|
||||
|
||||
Revision ID: b4c5d6e7f809
|
||||
Revises: a3b4c5d6e7f8
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "b4c5d6e7f809"
|
||||
down_revision = "a3b4c5d6e7f8"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.drop_column("questions", "is_shared")
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Back as it was: visible by default, which is what every row held.
|
||||
op.add_column("questions",
|
||||
sa.Column("is_shared", sa.Integer(), nullable=True, server_default="1"))
|
||||
|
|
@ -262,10 +262,6 @@ def setup_pgvector():
|
|||
ALTER TABLE questions
|
||||
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE SET NULL
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE questions
|
||||
ADD COLUMN IF NOT EXISTS is_shared INTEGER DEFAULT 1
|
||||
"""))
|
||||
|
||||
# Fix email collation to avoid en_US.utf8 B-tree index corruption
|
||||
conn.execute(text('ALTER TABLE users ALTER COLUMN email TYPE varchar COLLATE "C"'))
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ class Question(Base):
|
|||
attending_tip = Column(Text, nullable=True)
|
||||
difficulty = Column(String(10), nullable=True) # easy | medium | hard
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
is_shared = Column(Integer, default=1) # 1 = visible in bank, 0 = private (only owner sees it)
|
||||
embedding = deferred(Column(Vector(settings.EMBEDDING_DIMENSIONS), nullable=True)) # semantic search vector — deferred: not loaded in standard queries
|
||||
# Which model produced `embedding`. Vectors from different models are not
|
||||
# comparable, so a model change must be detectable rather than silent.
|
||||
|
|
|
|||
|
|
@ -219,25 +219,6 @@ def edit_question(
|
|||
}
|
||||
|
||||
|
||||
@router.patch("/{question_id}/share")
|
||||
def toggle_question_share(
|
||||
question_id: int,
|
||||
shared: int = Query(..., description="1 = shared, 0 = private"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Toggle question sharing. Owner or admin can change."""
|
||||
question = db.query(Question).filter(Question.id == question_id).first()
|
||||
if not question:
|
||||
raise HTTPException(status_code=404, detail="Question not found")
|
||||
is_admin = current_user.role in ("admin", "moderator")
|
||||
if question.user_id != current_user.id and not is_admin:
|
||||
raise HTTPException(status_code=403, detail="Not your question")
|
||||
question.is_shared = 1 if shared else 0
|
||||
db.commit()
|
||||
return {"id": question_id, "is_shared": question.is_shared}
|
||||
|
||||
|
||||
@router.patch("/{question_id}/category")
|
||||
def set_question_category(
|
||||
question_id: int,
|
||||
|
|
@ -377,8 +358,6 @@ def get_question_bank(
|
|||
query = query.filter(or_(Question.explanation.is_(None), Question.explanation.in_(("", " "))))
|
||||
elif needs == "difficulty":
|
||||
query = query.filter(Question.difficulty.is_(None))
|
||||
elif needs == "private":
|
||||
query = query.filter(Question.is_shared == 0)
|
||||
|
||||
# Tag filter: questions must have ALL specified tags
|
||||
if tag_ids:
|
||||
|
|
@ -461,7 +440,6 @@ def get_question_bank(
|
|||
"attending_tip": qu.attending_tip,
|
||||
"difficulty": qu.difficulty,
|
||||
"user_id": qu.user_id,
|
||||
"is_shared": qu.is_shared if qu.is_shared is not None else 1,
|
||||
"match_source": "semantic" if qu.id in semantic_ids else "keyword",
|
||||
})
|
||||
|
||||
|
|
@ -532,7 +510,6 @@ def create_question_manually(
|
|||
attending_tip=(data.attending_tip or None),
|
||||
difficulty=data.difficulty,
|
||||
user_id=current_user.id,
|
||||
is_shared=1,
|
||||
)
|
||||
db.add(question)
|
||||
db.commit()
|
||||
|
|
@ -597,7 +574,6 @@ def list_question_images(
|
|||
def count_builder_questions(
|
||||
category_ids: list[int] = Query(default=[]),
|
||||
state: Literal["all", "unused", "incorrect", "bookmarked"] = "all",
|
||||
is_shared: bool = False,
|
||||
difficulty: Literal["easy", "medium", "hard"] | None = Query(None),
|
||||
article_ids: str | None = Query(None, description="Comma-separated article IDs (OR filter)"),
|
||||
tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"),
|
||||
|
|
@ -608,7 +584,7 @@ def count_builder_questions(
|
|||
ids = [int(part) for part in (article_ids or "").split(",") if part.strip().isdigit()]
|
||||
tag_list = [int(part) for part in (tag_ids or "").split(",") if part.strip().isdigit()]
|
||||
systems = [int(part) for part in (system_ids or "").split(",") if part.strip().isdigit()]
|
||||
return {"count": filtered_bank_query(db, current_user, category_ids, state, is_shared,
|
||||
return {"count": filtered_bank_query(db, current_user, category_ids, state,
|
||||
difficulty, ids, tag_list, systems).count()}
|
||||
|
||||
|
||||
|
|
@ -742,10 +718,9 @@ class BulkQuestionAction(BaseModel):
|
|||
"""One editorial action applied to a checked set in the question manager."""
|
||||
|
||||
question_ids: list[int]
|
||||
action: Literal["category", "difficulty", "share", "delete"]
|
||||
action: Literal["category", "difficulty", "delete"]
|
||||
category_id: int | None = None
|
||||
difficulty: Literal["easy", "medium", "hard"] | None = None
|
||||
shared: int | None = None
|
||||
|
||||
|
||||
@router.post("/bulk")
|
||||
|
|
@ -772,8 +747,6 @@ def bulk_question_action(
|
|||
updated = rows.update({"question_category_id": data.category_id}, synchronize_session=False)
|
||||
elif data.action == "difficulty":
|
||||
updated = rows.update({"difficulty": data.difficulty}, synchronize_session=False)
|
||||
elif data.action == "share":
|
||||
updated = rows.update({"is_shared": 1 if data.shared else 0}, synchronize_session=False)
|
||||
else: # delete
|
||||
# Extra category links cascade with the question rows.
|
||||
db.query(QuestionCategoryLink).filter(
|
||||
|
|
@ -938,7 +911,6 @@ def get_question_detail(
|
|||
"image_path": question.image_path,
|
||||
"explanation_image_path": question.explanation_image_path,
|
||||
"user_id": question.user_id,
|
||||
"is_shared": question.is_shared if question.is_shared is not None else 1,
|
||||
"source_quiz_id": question.source_quiz_id,
|
||||
}
|
||||
|
||||
|
|
@ -967,7 +939,6 @@ def question_manager_summary(
|
|||
"no_difficulty": base.filter(Question.difficulty.is_(None)).scalar() or 0,
|
||||
"no_explanation": base.filter(
|
||||
or_(Question.explanation.is_(None), Question.explanation.in_(blank))).scalar() or 0,
|
||||
"private": base.filter(Question.is_shared == 0).scalar() or 0,
|
||||
"mine": base.filter(Question.user_id == current_user.id).scalar() or 0,
|
||||
"scoped": scope is not None,
|
||||
}
|
||||
|
|
@ -1278,7 +1249,6 @@ def import_qti(
|
|||
correct_answer=correct_answer,
|
||||
explanation=explanation or None,
|
||||
user_id=current_user.id,
|
||||
is_shared=0,
|
||||
)
|
||||
db.add(q)
|
||||
created.append(q)
|
||||
|
|
|
|||
|
|
@ -78,7 +78,6 @@ def accept(db: Session, draft: DraftQuestion, batch: DraftBatch, user: User) ->
|
|||
image_path=draft.image_path,
|
||||
explanation_image_path=draft.explanation_image_path,
|
||||
user_id=user.id,
|
||||
is_shared=1,
|
||||
)
|
||||
db.add(question)
|
||||
db.flush()
|
||||
|
|
|
|||
|
|
@ -58,13 +58,28 @@ def general_question_predicate():
|
|||
|
||||
|
||||
def shareable_question_predicate():
|
||||
return general_question_predicate() & or_(Question.is_shared == 1, Question.is_shared.is_(None))
|
||||
"""What a public share link may contain.
|
||||
|
||||
Once it was "questions whose author ticked shared". Per-question sharing is
|
||||
gone: a question in the bank is in the bank, and who may *manage* one is
|
||||
decided by the grant tree rather than by a flag its author set. So the only
|
||||
thing still excluded here is what is excluded everywhere — a deleted
|
||||
question, and one belonging to a course.
|
||||
"""
|
||||
return general_question_predicate()
|
||||
|
||||
|
||||
def bank_question_predicate(user):
|
||||
return general_question_predicate() & or_(
|
||||
Question.is_shared == 1, Question.is_shared.is_(None), Question.user_id == user.id,
|
||||
)
|
||||
"""What a learner's bank holds.
|
||||
|
||||
Every question in it. The flag this used to consult defaulted to 1 and was
|
||||
settable only from a route nothing called, so in practice it divided the
|
||||
bank into "everything" and "everything, plus your own private ones" — a
|
||||
distinction that cost every recommendation denominator a join and never
|
||||
changed an answer.
|
||||
"""
|
||||
del user # Kept in the signature: the exam scope and grants still take one.
|
||||
return general_question_predicate()
|
||||
|
||||
|
||||
def exam_scope_predicate(db, user):
|
||||
|
|
@ -91,7 +106,7 @@ def bank_query(db, user):
|
|||
return db.query(Question).filter(bank_question_predicate(user))
|
||||
|
||||
|
||||
def filtered_bank_query(db, user, category_ids=(), state="all", shared=False, difficulty=None, article_ids=(), tag_ids=(), system_ids=()):
|
||||
def filtered_bank_query(db, user, category_ids=(), state="all", difficulty=None, article_ids=(), tag_ids=(), system_ids=()):
|
||||
query = bank_query(db, user)
|
||||
if difficulty:
|
||||
query = query.filter(Question.difficulty == difficulty)
|
||||
|
|
@ -140,8 +155,6 @@ def filtered_bank_query(db, user, category_ids=(), state="all", shared=False, di
|
|||
Question.question_category_id.in_(ids),
|
||||
Question.id.in_(select(QuestionCategoryLink.question_id).where(QuestionCategoryLink.category_id.in_(ids))),
|
||||
))
|
||||
if shared:
|
||||
query = query.filter(shareable_question_predicate())
|
||||
if state == "bookmarked":
|
||||
query = query.filter(Question.id.in_(select(Favorite.question_id).where(Favorite.user_id == user.id)))
|
||||
elif state in ("unused", "incorrect"):
|
||||
|
|
@ -388,7 +401,7 @@ def generate_test(db, user, data):
|
|||
if len(ids) < data.count:
|
||||
raise HTTPException(400, f"Only {len(ids)} questions available; requested {data.count}")
|
||||
return create_saved_test(db, user, data, ids)
|
||||
query = filtered_bank_query(db, user, data.category_ids, data.state, data.is_shared,
|
||||
query = filtered_bank_query(db, user, data.category_ids, data.state,
|
||||
data.difficulty, data.article_ids, data.tag_ids, data.system_ids)
|
||||
if data.explicit_ids:
|
||||
query = query.filter(Question.id.in_(list(dict.fromkeys(data.explicit_ids))))
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import os
|
|||
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
|
||||
|
||||
import io
|
||||
from datetime import datetime
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
|
@ -33,12 +34,14 @@ class AiModeMatchingTests(unittest.TestCase):
|
|||
self.user = User(id=1, name="Learner", email="learner@example.test", hashed_password="unused")
|
||||
self.other = User(id=2, name="Other", email="other@example.test", hashed_password="unused")
|
||||
self.db.add_all([self.user, self.other])
|
||||
for qid, text, owner, shared in [
|
||||
(1, "A child with fever and a seizure", 1, 1),
|
||||
(2, "An infant with jaundice", 1, 1),
|
||||
(3, "A private question of someone else", 2, 0),
|
||||
# A question that nobody may match against is a deleted one; the flag
|
||||
# an author could set to keep one back no longer exists.
|
||||
for qid, text, owner, deleted in [
|
||||
(1, "A child with fever and a seizure", 1, None),
|
||||
(2, "An infant with jaundice", 1, None),
|
||||
(3, "A question of someone else, since removed", 2, datetime(2026, 1, 1)),
|
||||
]:
|
||||
self.db.add(Question(id=qid, user_id=owner, is_shared=shared, question_text=text,
|
||||
self.db.add(Question(id=qid, user_id=owner, deleted_at=deleted, question_text=text,
|
||||
question_type="mcq", options=["yes", "no"], correct_answer="yes"))
|
||||
self.db.commit()
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ to go and practise it. What is under test is which questions that lands on,
|
|||
and that a chat cannot reach a question the learner could not otherwise see.
|
||||
"""
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
import test_quiz_builder as fixtures
|
||||
|
|
@ -61,11 +62,13 @@ class PracticeFromChatTests(unittest.TestCase):
|
|||
response = self.practise(cid)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
quiz = self.client.get(f"/quizzes/{response.json()['quiz_id']}").json()
|
||||
self.assertEqual([q['id'] for q in quiz['questions']], [2])
|
||||
self.assertEqual(sorted(q['id'] for q in quiz['questions']), [2, 4])
|
||||
|
||||
def test_a_chat_cannot_reach_a_question_the_learner_may_not_see(self):
|
||||
# Question 4 belongs to another learner and is not shared. A citation
|
||||
# could only name it by mistake; the bank's own rules answer anyway.
|
||||
# Question 4 has been removed from the bank. A citation could only name
|
||||
# it by mistake; the bank's own rules answer anyway.
|
||||
self.bank.db.get(fixtures.Question, 4).deleted_at = datetime(2026, 1, 1)
|
||||
self.bank.db.commit()
|
||||
cid, _ = self.thread([{'marker': '[[question:4]]', 'kind': 'question', 'id': 4,
|
||||
'title': 'Question #4'}])
|
||||
self.assertEqual(self.practise(cid).status_code, 404)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Article library and card association routes on disposable SQLite; no network/AI."""
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
import test_quiz_builder as fixtures
|
||||
from app.models.article import Article, QuestionArticleLink
|
||||
|
|
@ -65,7 +66,7 @@ class ArticlesCardsTests(unittest.TestCase):
|
|||
self.assertEqual(link.json()['linked'], True)
|
||||
self.assertEqual(self.client.put(f"/articles/{article['id']}/links", json={'question_id': 1, 'section_id': section_id}).json()['linked'], False)
|
||||
self.assertEqual(self.client.put(f"/articles/{article['id']}/links", json={'question_id': 1, 'section_id': 'f' * 32}).status_code, 400)
|
||||
self.assertEqual(self.client.put(f"/articles/{article['id']}/links", json={'question_id': 4, 'section_id': section_id}).status_code, 404)
|
||||
self.assertEqual(self.client.put(f"/articles/{article['id']}/links", json={'question_id': 999, 'section_id': section_id}).status_code, 404)
|
||||
self.bank.user = self.bank.owner
|
||||
self.assertEqual(self.client.put(f"/articles/{article['id']}/links", json={'question_id': 1}).status_code, 403)
|
||||
self.bank.user = self.bank.mod
|
||||
|
|
@ -101,7 +102,7 @@ class ArticlesCardsTests(unittest.TestCase):
|
|||
self.bank.user = self.bank.owner
|
||||
questions = self.client.get(f"/articles/{article['id']}/questions").json()
|
||||
self.assertEqual([q['question_id'] for q in questions], [1])
|
||||
self.bank.db.get(Question, 1).is_shared = 0
|
||||
self.bank.db.get(Question, 1).deleted_at = datetime(2026, 1, 1)
|
||||
self.bank.db.commit()
|
||||
self.bank.user = self.bank.peer
|
||||
self.assertEqual(self.client.get(f"/articles/{article['id']}/questions").json(), [])
|
||||
|
|
@ -125,7 +126,7 @@ class ArticlesCardsTests(unittest.TestCase):
|
|||
self.assertEqual(self.client.post(f'/flashcards/decks/{deck.id}/cards', json={'front': 'F', 'back': 'B'}).status_code, 403)
|
||||
self.bank.user = self.bank.mod
|
||||
self.assertEqual(self.client.put(f"/flashcards/cards/{card['id']}/links/question", json={'question_id': 1}).status_code, 200)
|
||||
self.assertEqual(self.client.put(f"/flashcards/cards/{card['id']}/links/question", json={'question_id': 4}).status_code, 404)
|
||||
self.assertEqual(self.client.put(f"/flashcards/cards/{card['id']}/links/question", json={'question_id': 999}).status_code, 404)
|
||||
self.assertEqual(self.client.put(f"/flashcards/cards/{card['id']}/links/article",
|
||||
json={'article_id': article['id'], 'article_section_id': 'f' * 32}).status_code, 400)
|
||||
self.assertEqual(self.client.put(f"/flashcards/cards/{card['id']}/links/article",
|
||||
|
|
@ -173,7 +174,7 @@ class ArticlesCardsTests(unittest.TestCase):
|
|||
self.assertEqual([q['id'] for q in links['questions']], [1])
|
||||
self.assertEqual(links['articles'], [])
|
||||
# Making the question private and publishing the article flips both filters.
|
||||
self.bank.db.get(Question, 1).is_shared = 0
|
||||
self.bank.db.get(Question, 1).deleted_at = datetime(2026, 1, 1)
|
||||
self.bank.db.commit()
|
||||
self.bank.user = self.bank.mod
|
||||
self.client.post(f"/articles/{article['id']}/publish", json={'published': True})
|
||||
|
|
|
|||
|
|
@ -155,8 +155,7 @@ class SqliteBankTests(unittest.TestCase):
|
|||
self.engine.dispose()
|
||||
|
||||
def add_question(self, **fields):
|
||||
question = Question(question_type="mcq", options=["a", "b"], correct_answer="a",
|
||||
is_shared=1, **fields)
|
||||
question = Question(question_type="mcq", options=["a", "b"], correct_answer="a", **fields)
|
||||
self.db.add(question)
|
||||
self.db.commit()
|
||||
return question
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ class CategoryGrantTests(unittest.TestCase):
|
|||
])
|
||||
self.db.flush()
|
||||
for qid, category in [(1, 1), (2, 2), (3, 10), (4, None)]:
|
||||
self.db.add(Question(id=qid, question_category_id=category, user_id=1, is_shared=1,
|
||||
self.db.add(Question(id=qid, question_category_id=category, user_id=1,
|
||||
question_text=f"Question {qid}", question_type="mcq",
|
||||
options=["yes", "no"], correct_answer="yes", explanation="Because"))
|
||||
self.db.commit()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ is the ordering: most recently used first, and a library nobody has opened
|
|||
sorts by when it was made rather than claiming a use that never happened.
|
||||
"""
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
import test_quiz_builder as fixtures
|
||||
from app.models.collection import UserCollection
|
||||
|
|
@ -84,7 +85,9 @@ class CollectionTests(unittest.TestCase):
|
|||
|
||||
def test_a_question_nobody_can_see_cannot_be_saved(self):
|
||||
row = self.make('Mine')
|
||||
# Question 4 is another learner's and not shared; question 3 is this
|
||||
# learner's own, unshared, and theirs to save.
|
||||
# Question 4 has been removed from the bank; question 3 is an ordinary
|
||||
# bank question and theirs to save.
|
||||
self.bank.db.get(fixtures.Question, 4).deleted_at = datetime(2026, 1, 1)
|
||||
self.bank.db.commit()
|
||||
self.assertEqual(self.client.put(f"/collections/{row['id']}/questions/4").status_code, 404)
|
||||
self.assertEqual(self.client.put(f"/collections/{row['id']}/questions/3").status_code, 200)
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ class ExamScopedTagTests(unittest.TestCase):
|
|||
Exam(id=2, slug="usmle-step-2-ck", name="USMLE Step 2 CK")])
|
||||
self.db.flush()
|
||||
for qid, exam_id in ((1, 1), (2, 1), (3, 2)):
|
||||
self.db.add(Question(id=qid, user_id=1, is_shared=1, question_text=f"Q{qid}",
|
||||
self.db.add(Question(id=qid, user_id=1, question_text=f"Q{qid}",
|
||||
question_type="mcq", options=["a", "b"], correct_answer="a"))
|
||||
self.db.flush()
|
||||
self.db.add(QuestionExamLink(question_id=qid, exam_id=exam_id))
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ class ExamTests(unittest.TestCase):
|
|||
])
|
||||
self.db.flush()
|
||||
for qid in (1, 2, 3):
|
||||
self.db.add(Question(id=qid, user_id=1, is_shared=1, question_text=f"Question {qid}",
|
||||
self.db.add(Question(id=qid, user_id=1, question_text=f"Question {qid}",
|
||||
question_type="mcq", options=["yes", "no"], correct_answer="yes"))
|
||||
self.db.flush()
|
||||
# 1 → boards, 2 → step 2, 3 → unlinked (unclassified, not excluded)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ The point of these tests is the boundary, not the ranking: a search page that
|
|||
had its own idea of who may see what is how private content leaks.
|
||||
"""
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
import test_quiz_builder as fixtures
|
||||
from app.models.article import Article, ArticleSectionIndex
|
||||
|
|
@ -42,7 +43,8 @@ class GlobalSearchTests(unittest.TestCase):
|
|||
self.db.query(Question).filter(Question.id == 1).update(
|
||||
{"question_text": "A child with a febrile seizure lasting two minutes"})
|
||||
self.db.query(Question).filter(Question.id == 3).update(
|
||||
{"question_text": "A private febrile seizure question"})
|
||||
{"question_text": "A removed febrile seizure question",
|
||||
"deleted_at": datetime(2026, 1, 1)})
|
||||
self.db.commit()
|
||||
|
||||
def tearDown(self):
|
||||
|
|
@ -76,10 +78,10 @@ class GlobalSearchTests(unittest.TestCase):
|
|||
self.bank.user = self.bank.mod
|
||||
self.assertIn(2, [row['id'] for row in self.find()['results']['article']])
|
||||
|
||||
def test_private_questions_and_other_peoples_decks_are_not_searchable(self):
|
||||
def test_removed_questions_and_other_peoples_decks_are_not_searchable(self):
|
||||
self.bank.user = self.bank.peer
|
||||
data = self.find()
|
||||
# Question 3 belongs to another user and is not shared.
|
||||
# Question 3 has been removed from the bank.
|
||||
self.assertNotIn(3, [row['id'] for row in data['results']['question']])
|
||||
# Deck 1 belongs to the owner; the peer's search must not reach into it.
|
||||
self.assertNotIn(1, [row['id'] for row in data['results']['flashcard']])
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class HybridSearchTests(unittest.TestCase):
|
|||
(2, "An infant with jaundice on day three"),
|
||||
(3, "A toddler with a febrile convulsion"),
|
||||
]:
|
||||
self.db.add(Question(id=qid, user_id=1, is_shared=1, question_text=text,
|
||||
self.db.add(Question(id=qid, user_id=1, question_text=text,
|
||||
question_type="mcq", options=["yes", "no"], correct_answer="yes"))
|
||||
self.db.commit()
|
||||
|
||||
|
|
@ -78,7 +78,7 @@ class EmbeddingProvenanceTests(unittest.TestCase):
|
|||
self.db = Session(self.engine)
|
||||
self.db.add(User(id=1, name="Mod", email="mod@example.test", hashed_password="unused", role="moderator"))
|
||||
for qid in (1, 2, 3):
|
||||
self.db.add(Question(id=qid, user_id=1, is_shared=1, question_text=f"Question {qid}",
|
||||
self.db.add(Question(id=qid, user_id=1, question_text=f"Question {qid}",
|
||||
question_type="mcq", options=["yes", "no"], correct_answer="yes"))
|
||||
self.db.commit()
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import io
|
||||
import os
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
|
@ -32,10 +33,10 @@ class MultiCategoryTests(unittest.TestCase):
|
|||
self.bank.user = self.bank.owner # Viewing counts stays open to learners.
|
||||
cats = {cat['id']: cat['question_count'] for cat in self.client.get('/question-categories/').json()}
|
||||
self.assertEqual(cats[4], 1) # Was an empty category; now includes the linked question.
|
||||
self.assertEqual(cats[2], 2) # Question 2 plus the additional link on question 3.
|
||||
self.assertEqual(cats[1], 3) # Question 3 was already counted through its primary (descendant of 1).
|
||||
self.assertEqual(cats[2], 3) # Questions 2 and 4, plus the additional link on question 3.
|
||||
self.assertEqual(cats[1], 4) # Question 3 was already counted through its primary (descendant of 1).
|
||||
self.assertEqual(self.client.get('/questions/builder/count', params={'category_ids': [4]}).json()['count'], 1)
|
||||
self.assertEqual(self.client.get('/questions/builder/count', params={'category_ids': [2]}).json()['count'], 2)
|
||||
self.assertEqual(self.client.get('/questions/builder/count', params={'category_ids': [2]}).json()['count'], 3)
|
||||
bank = self.client.get('/questions/bank', params={'category_ids': '4'}).json()
|
||||
self.assertEqual([row['id'] for row in bank['questions']], [3])
|
||||
self.assertEqual(set(bank['questions'][0]['category_ids']), {2, 3, 4})
|
||||
|
|
@ -43,8 +44,9 @@ class MultiCategoryTests(unittest.TestCase):
|
|||
# Category quiz creation counts extra-linked questions too.
|
||||
created = self.client.post('/question-categories/4/create-quiz', params={'title': 'Linked quiz', 'mode': 'learning'})
|
||||
self.assertEqual(created.status_code, 200, created.text)
|
||||
# Peers cannot see the private question through the shared category.
|
||||
self.bank.user = self.bank.peer
|
||||
# A removed question is not reachable through its category either.
|
||||
self.bank.db.get(Question, 3).deleted_at = datetime(2026, 1, 1)
|
||||
self.bank.db.commit()
|
||||
self.assertEqual(self.client.get('/questions/builder/count', params={'category_ids': [4]}).json()['count'], 0)
|
||||
|
||||
def test_additional_categories_replace_validate_and_ignore_primary(self):
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class QuestionVersionTests(unittest.TestCase):
|
|||
self.mod = User(id=1, name="Mod", email="mod@example.test", hashed_password="unused", role="moderator")
|
||||
self.learner = User(id=2, name="Learner", email="learner@example.test", hashed_password="unused")
|
||||
self.db.add_all([self.mod, self.learner])
|
||||
self.db.add(Question(id=1, user_id=1, is_shared=1, question_text="Original stem",
|
||||
self.db.add(Question(id=1, user_id=1, question_text="Original stem",
|
||||
question_type="mcq", options=["yes", "no"], correct_answer="yes",
|
||||
explanation="Original explanation"))
|
||||
self.db.commit()
|
||||
|
|
|
|||
|
|
@ -55,16 +55,17 @@ class BuilderTests(unittest.TestCase):
|
|||
self.db.add_all([Quiz(id=1, title="Origin", user_id=3, is_published=1),
|
||||
Quiz(id=2, title="Course quiz", user_id=3, course_id=1, is_published=1, is_shared=1, allow_review=0)])
|
||||
self.db.flush()
|
||||
for qid, category, owner, shared, source in [
|
||||
(1, 1, 3, 1, 1), (2, 2, 3, 1, 1), (3, 3, 1, 0, None),
|
||||
(4, 2, 2, 0, None), (5, 2, 3, 1, 2), (6, None, 3, 1, None),
|
||||
# Per-question sharing is gone: every question in the bank is in the
|
||||
# bank, and who may manage one is the grant tree's business.
|
||||
for qid, category, owner, source in [
|
||||
(1, 1, 3, 1), (2, 2, 3, 1), (3, 3, 1, None),
|
||||
(4, 2, 2, None), (5, 2, 3, 2), (6, None, 3, None),
|
||||
]:
|
||||
self.db.add(Question(id=qid, question_category_id=category, user_id=owner,
|
||||
is_shared=shared, source_quiz_id=source, question_text=f"Question {qid}",
|
||||
source_quiz_id=source, question_text=f"Question {qid}",
|
||||
question_type="mcq", options=["yes", "no"], correct_answer="yes",
|
||||
explanation="Full explanation", image_path="q.png", explanation_image_path="answer.png"))
|
||||
self.db.flush()
|
||||
self.db.query(Question).filter(Question.id == 2).update({"is_shared": None})
|
||||
self.db.add_all([QuizQuestionLink(quiz_id=1, question_id=1, position=0),
|
||||
QuizQuestionLink(quiz_id=1, question_id=2, position=1),
|
||||
QuizQuestionLink(quiz_id=2, question_id=5, position=0)])
|
||||
|
|
@ -86,6 +87,13 @@ class BuilderTests(unittest.TestCase):
|
|||
def generate(self, **overrides):
|
||||
return self.client.post("/questions/builder", json={"title": " Test ", "count": 2, **overrides})
|
||||
|
||||
def saved_test(self, ids, **overrides):
|
||||
"""A test built from named questions, for the tests that care which."""
|
||||
response = self.client.post("/questions/from-bank",
|
||||
json={"title": "Named", "question_ids": ids, **overrides})
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
return response.json()["id"]
|
||||
|
||||
def count(self, **params):
|
||||
response = self.client.get("/questions/builder/count", params=params)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
|
|
@ -104,10 +112,10 @@ class BuilderTests(unittest.TestCase):
|
|||
from app.services.quiz_builder import adaptive_select
|
||||
self.answer(1, False, day=0) # Question 1 (cat 1): incorrect — recycled first.
|
||||
self.answer(2, True, day=1) # Question 2 (cat 2): correct.
|
||||
ids = adaptive_select(self.db, self.owner, 4, [], 'all', None)
|
||||
self.assertEqual(set(ids[:2]), {3, 6}) # Unanswered questions preferred.
|
||||
self.assertEqual(ids[2], 1) # Older incorrect resurfaces first.
|
||||
self.assertEqual(ids[3], 2)
|
||||
ids = adaptive_select(self.db, self.owner, 5, [], 'all', None)
|
||||
self.assertEqual(set(ids[:3]), {3, 4, 6}) # Unanswered questions preferred.
|
||||
self.assertEqual(ids[3], 1) # Older incorrect resurfaces first.
|
||||
self.assertEqual(ids[4], 2)
|
||||
|
||||
def test_difficulty_tags_filter_and_validate(self):
|
||||
self.user = self.mod
|
||||
|
|
@ -122,22 +130,21 @@ class BuilderTests(unittest.TestCase):
|
|||
|
||||
def test_descendants_counts_visibility_and_invalid_categories(self):
|
||||
self.assertEqual(category_descendants(self.db.query(QuestionCategory).all(), [1, 2]), {1, 2, 3})
|
||||
self.assertEqual(self.count(category_ids=[1, 2]), 3)
|
||||
self.assertEqual(self.count(category_ids=[1], is_shared=True), 2)
|
||||
self.assertEqual(self.count(), 4)
|
||||
self.assertEqual(self.count(category_ids=[1, 2]), 4)
|
||||
self.assertEqual(self.count(), 5)
|
||||
cats = self.client.get("/question-categories/").json()
|
||||
root = next(c for c in cats if c["id"] == 1)
|
||||
leaf = next(c for c in cats if c["id"] == 3)
|
||||
self.assertEqual(root["question_count"], 3)
|
||||
self.assertEqual(root["question_count"], 4)
|
||||
self.assertEqual([b["id"] for b in leaf["breadcrumbs"]], [1, 2, 3])
|
||||
self.assertEqual(self.client.get("/questions/builder/count?category_ids=999").status_code, 400)
|
||||
self.assertEqual(self.generate(category_ids=[999]).status_code, 400)
|
||||
self.assertEqual(self.client.get("/questions/bank/ids?category_ids=1,bad").status_code, 400)
|
||||
self.assertEqual(self.client.get("/questions/bank?category_ids=-1").status_code, 400)
|
||||
self.assertEqual(self.client.get("/questions/export/qti?question_ids=1,4").status_code, 400)
|
||||
self.assertEqual(self.client.get("/questions/export/qti?question_ids=1,5").status_code, 400)
|
||||
bank = self.client.get("/questions/bank", params={"category_ids": "1,2", "search_mode": "keyword"}).json()
|
||||
self.assertEqual(bank["total"], 3)
|
||||
self.assertEqual(set(self.client.get("/questions/bank/ids").json()), {1, 2, 3, 6})
|
||||
self.assertEqual(bank["total"], 4)
|
||||
self.assertEqual(set(self.client.get("/questions/bank/ids").json()), {1, 2, 3, 4, 6})
|
||||
|
||||
def test_parent_cycle_missing_self_delete_and_saved_history(self):
|
||||
saved = self.generate(category_ids=[1], count=3).json()["id"]
|
||||
|
|
@ -160,7 +167,7 @@ class BuilderTests(unittest.TestCase):
|
|||
self.assertEqual(self.client.post("/question-categories/", json={"name": "New", "parent_id": 999}).status_code, 400)
|
||||
|
||||
def test_sampling_exact_zero_insufficient_stale_and_validation(self):
|
||||
for payload, status in [({"category_ids": [4]}, 400), ({"count": 5}, 400), ({"expected_count": 99}, 409),
|
||||
for payload, status in [({"category_ids": [4]}, 400), ({"count": 6}, 400), ({"expected_count": 99}, 409),
|
||||
({"count": 0}, 422), ({"count": 201}, 422), ({"mode": "bad"}, 422),
|
||||
({"title": " "}, 422), ({"title": "x" * 201}, 422), ({"time_limit_minutes": 0}, 422), ({"state": "bad"}, 422)]:
|
||||
self.assertEqual(self.generate(**payload).status_code, status)
|
||||
|
|
@ -168,20 +175,20 @@ class BuilderTests(unittest.TestCase):
|
|||
sampled_ids = [q.id for q in self.db.get(Quiz, sampled["id"]).questions]
|
||||
self.assertEqual(len(sampled_ids), 2)
|
||||
self.assertEqual(len(set(sampled_ids)), 2)
|
||||
self.assertTrue(set(sampled_ids) <= {1, 2, 3, 6})
|
||||
result = self.generate(count=4, expected_count=4, mode="learning")
|
||||
self.assertTrue(set(sampled_ids) <= {1, 2, 3, 4, 6})
|
||||
result = self.generate(count=5, expected_count=5, mode="learning")
|
||||
self.assertEqual(result.status_code, 200, result.text)
|
||||
quiz = self.db.get(Quiz, result.json()["id"])
|
||||
self.assertEqual(quiz.title, "Test")
|
||||
self.assertEqual(quiz.is_shared, 0)
|
||||
self.assertEqual(quiz.is_published, 0)
|
||||
self.assertEqual({q.id for q in quiz.questions}, {1, 2, 3, 6})
|
||||
self.assertEqual(len(quiz.questions), 4)
|
||||
self.assertEqual({q.id for q in quiz.questions}, {1, 2, 3, 4, 6})
|
||||
self.assertEqual(len(quiz.questions), 5)
|
||||
self.assertEqual(quiz.questions[0].explanation_image_path, "answer.png")
|
||||
first = self.client.post(f"/attempts/start?quiz_id={quiz.id}").json()
|
||||
again = self.client.post(f"/attempts/start?quiz_id={quiz.id}").json()
|
||||
self.assertEqual(first["id"], again["id"])
|
||||
self.assertEqual({q["id"] for q in self.client.get(f"/quizzes/{quiz.id}").json()["questions"]}, {1, 2, 3, 6})
|
||||
self.assertEqual({q["id"] for q in self.client.get(f"/quizzes/{quiz.id}").json()["questions"]}, {1, 2, 3, 4, 6})
|
||||
|
||||
def test_latest_incorrect_unused_bookmarks(self):
|
||||
self.answer(1, False, day=0)
|
||||
|
|
@ -192,7 +199,7 @@ class BuilderTests(unittest.TestCase):
|
|||
self.answer(3, False, day=1, quiz_id=2) # course answers never count
|
||||
self.db.add_all([Favorite(user_id=1, question_id=2), Favorite(user_id=1, question_id=5), Favorite(user_id=2, question_id=1)])
|
||||
self.db.commit()
|
||||
self.assertEqual(self.count(state="unused"), 2)
|
||||
self.assertEqual(self.count(state="unused"), 3)
|
||||
self.assertEqual(self.count(state="incorrect"), 1)
|
||||
self.assertEqual(self.count(state="bookmarked"), 1)
|
||||
res = self.generate(state="incorrect", count=1).json()
|
||||
|
|
@ -201,7 +208,7 @@ class BuilderTests(unittest.TestCase):
|
|||
self.assertEqual(self.count(state="incorrect"), 0)
|
||||
|
||||
def test_explicit_ids_atomic_permissions_order_and_category_creator(self):
|
||||
for ids in ([1, 4], [1, 5], [1, 999]):
|
||||
for ids in ([1, 5], [1, 999]):
|
||||
before = self.db.query(Quiz).count()
|
||||
res = self.client.post("/questions/from-bank", json={"title": "X", "question_ids": ids})
|
||||
self.assertEqual(res.status_code, 400, res.text)
|
||||
|
|
@ -215,10 +222,15 @@ class BuilderTests(unittest.TestCase):
|
|||
|
||||
def test_shared_private_revocation_and_mobile(self):
|
||||
private_id = self.generate(category_ids=[3], count=1).json()["id"]
|
||||
# A deleted question is out of everyone's reach, so a session holding
|
||||
# one cannot be handed on. Per-question sharing used to be the other
|
||||
# reason; there is no such flag now.
|
||||
self.db.get(Question, 3).deleted_at = datetime(2026, 1, 1)
|
||||
self.db.commit()
|
||||
res = self.client.patch(f"/quizzes/{private_id}/share?shared=true")
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertEqual(self.client.post("/questions/from-bank", json={"title": "X", "question_ids": [3], "is_shared": True}).status_code, 400)
|
||||
shared_id = self.generate(is_shared=True, count=2, category_ids=[1]).json()["id"]
|
||||
shared_id = self.saved_test([1, 2], is_shared=True)
|
||||
self.user = self.peer
|
||||
self.assertEqual(self.client.get(f"/quizzes/{private_id}").status_code, 403)
|
||||
self.assertEqual(self.client.post(f"/quizzes/{private_id}/shuffle").status_code, 403)
|
||||
|
|
@ -244,10 +256,13 @@ class BuilderTests(unittest.TestCase):
|
|||
self.assertEqual(self.client.post(f"/attempts/start?quiz_id={shared_id}").status_code, 403)
|
||||
|
||||
def test_question_revocation_legacy_publication_and_course_enrollment(self):
|
||||
saved_id = self.generate(is_shared=True, category_ids=[1], count=2).json()["id"]
|
||||
saved_id = self.saved_test([1, 2], is_shared=True)
|
||||
self.user = self.peer
|
||||
self.assertEqual(self.client.get("/quizzes/1").status_code, 200) # published legacy quiz
|
||||
self.db.get(Question, 1).is_shared = 0
|
||||
# Deleting a question revokes every session that carried it. That is
|
||||
# the whole of per-question revocation now: the flag an author could
|
||||
# set is gone, and a question either exists or it does not.
|
||||
self.db.get(Question, 1).deleted_at = datetime(2026, 1, 1)
|
||||
self.db.commit()
|
||||
self.assertEqual(self.client.get("/quizzes/1").status_code, 403)
|
||||
self.user = self.owner
|
||||
|
|
@ -269,8 +284,8 @@ class BuilderTests(unittest.TestCase):
|
|||
def test_ownerless_question_revocation_denies_saved_owner(self):
|
||||
self.db.get(Question, 1).user_id = None
|
||||
self.db.commit()
|
||||
saved = self.generate(is_shared=True, category_ids=[1], count=2).json()["id"]
|
||||
self.db.get(Question, 1).is_shared = 0
|
||||
saved = self.saved_test([1, 2], is_shared=True)
|
||||
self.db.get(Question, 1).deleted_at = datetime(2026, 1, 1)
|
||||
self.db.commit()
|
||||
for user in (self.owner, self.peer):
|
||||
self.user = user
|
||||
|
|
@ -302,7 +317,7 @@ class BuilderTests(unittest.TestCase):
|
|||
self.assertEqual(self.db.query(AttemptAnswer).filter_by(attempt_id=attempt.id, question_id=3).count(), 1)
|
||||
|
||||
def test_real_submissions_record_skips_and_latest_outcome(self):
|
||||
saved = self.generate(is_shared=True, category_ids=[1], count=2).json()["id"]
|
||||
saved = self.saved_test([1, 2], is_shared=True)
|
||||
first = self.client.post(f"/attempts/start?quiz_id={saved}").json()["id"]
|
||||
self.assertEqual(self.client.get(f"/attempts/{first}").json()["answers"], [])
|
||||
redis = Mock()
|
||||
|
|
@ -314,17 +329,17 @@ class BuilderTests(unittest.TestCase):
|
|||
skipped = self.db.query(AttemptAnswer).filter_by(attempt_id=first, question_id=2).one()
|
||||
self.assertEqual((skipped.user_answer, skipped.is_correct), ("", False))
|
||||
self.assertEqual(self.count(state="incorrect"), 1)
|
||||
self.assertEqual(self.count(state="unused"), 2)
|
||||
self.assertEqual(self.count(state="unused"), 3)
|
||||
second = self.client.post(f"/attempts/start?quiz_id={saved}&fresh=true").json()["id"]
|
||||
with patch.dict(sys.modules, {"redis": redis}):
|
||||
result = self.client.post(f"/attempts/{second}/submit", json={"answers": []})
|
||||
self.assertEqual(result.status_code, 200, result.text)
|
||||
self.assertEqual(result.json()["score"], 0)
|
||||
self.assertEqual(self.count(state="incorrect"), 2)
|
||||
self.assertEqual(self.count(state="unused"), 2)
|
||||
self.assertEqual(self.count(state="unused"), 3)
|
||||
|
||||
def test_submission_rejects_duplicates_and_out_of_pool_atomically(self):
|
||||
saved = self.generate(is_shared=True, category_ids=[1], count=2).json()["id"]
|
||||
saved = self.saved_test([1, 2], is_shared=True)
|
||||
aid = self.client.post(f"/attempts/start?quiz_id={saved}").json()["id"]
|
||||
attempt = self.db.get(QuizAttempt, aid)
|
||||
attempt.selected_question_ids = [1]
|
||||
|
|
@ -344,7 +359,7 @@ class BuilderTests(unittest.TestCase):
|
|||
|
||||
def test_mobile_and_expiry_use_same_selected_question_grading(self):
|
||||
import json
|
||||
saved = self.generate(is_shared=True, category_ids=[1], count=2).json()["id"]
|
||||
saved = self.saved_test([1, 2], is_shared=True)
|
||||
before = self.db.query(QuizAttempt).count()
|
||||
for selected, answers in [([1, 1], []), ([999], []), ([1], [2]), ([1], [1, 1])]:
|
||||
result = self.client.post("/mobile/attempts", json={"quiz_id": saved, "selected_question_ids": selected,
|
||||
|
|
|
|||
|
|
@ -149,15 +149,15 @@ class QuestionManagerTests(unittest.TestCase):
|
|||
self.db.add_all([QuestionCategory(id=1, name="Cardiology", user_id=3),
|
||||
QuestionCategory(id=2, name="Neonatology", user_id=3)])
|
||||
self.db.flush()
|
||||
# 1: complete · 2: no explanation · 3: uncategorized + no difficulty · 4: private
|
||||
for qid, category, difficulty, explanation, shared in [
|
||||
(1, 1, "hard", "Because…", 1),
|
||||
(2, 1, "easy", "", 1),
|
||||
(3, None, None, "Because…", 1),
|
||||
(4, 2, "medium", "Because…", 0),
|
||||
# 1: complete · 2: no explanation · 3: uncategorized + no difficulty
|
||||
for qid, category, difficulty, explanation in [
|
||||
(1, 1, "hard", "Because…"),
|
||||
(2, 1, "easy", ""),
|
||||
(3, None, None, "Because…"),
|
||||
(4, 2, "medium", "Because…"),
|
||||
]:
|
||||
self.db.add(Question(id=qid, question_category_id=category, difficulty=difficulty,
|
||||
explanation=explanation, is_shared=shared, user_id=3,
|
||||
explanation=explanation, user_id=3,
|
||||
question_text=f"Question {qid}", question_type="mcq",
|
||||
options=["yes", "no"], correct_answer="yes"))
|
||||
self.db.commit()
|
||||
|
|
@ -185,7 +185,6 @@ class QuestionManagerTests(unittest.TestCase):
|
|||
self.assertEqual(summary["uncategorized"], 1)
|
||||
self.assertEqual(summary["no_explanation"], 1)
|
||||
self.assertEqual(summary["no_difficulty"], 1)
|
||||
self.assertEqual(summary["private"], 1)
|
||||
|
||||
def test_summary_is_moderator_only(self):
|
||||
self.user = self.owner
|
||||
|
|
@ -195,10 +194,9 @@ class QuestionManagerTests(unittest.TestCase):
|
|||
self.assertEqual(self.bank_ids(needs="category"), {3})
|
||||
self.assertEqual(self.bank_ids(needs="explanation"), {2})
|
||||
self.assertEqual(self.bank_ids(needs="difficulty"), {3})
|
||||
self.assertEqual(self.bank_ids(needs="private"), {4})
|
||||
self.assertEqual(self.client.get("/questions/bank", params={"needs": "nonsense"}).status_code, 422)
|
||||
|
||||
def test_bulk_sets_category_difficulty_and_sharing(self):
|
||||
def test_bulk_sets_category_and_difficulty(self):
|
||||
response = self.client.post("/questions/bulk",
|
||||
json={"question_ids": [3], "action": "category", "category_id": 2})
|
||||
self.assertEqual(response.json()["updated"], 1)
|
||||
|
|
@ -207,8 +205,10 @@ class QuestionManagerTests(unittest.TestCase):
|
|||
self.client.post("/questions/bulk", json={"question_ids": [2, 3], "action": "difficulty", "difficulty": "easy"})
|
||||
self.assertEqual([self.db.get(Question, i).difficulty for i in (2, 3)], ["easy", "easy"])
|
||||
|
||||
self.client.post("/questions/bulk", json={"question_ids": [4], "action": "share", "shared": 1})
|
||||
self.assertEqual(self.db.get(Question, 4).is_shared, 1)
|
||||
# Sharing is no longer one of the actions: there is no per-question
|
||||
# flag left for it to set.
|
||||
self.assertEqual(self.client.post("/questions/bulk",
|
||||
json={"question_ids": [4], "action": "share"}).status_code, 422)
|
||||
|
||||
def test_bulk_delete_removes_questions_and_their_category_links(self):
|
||||
self.db.add(QuestionCategoryLink(question_id=1, category_id=2))
|
||||
|
|
@ -250,9 +250,9 @@ class RecommendationTests(unittest.TestCase):
|
|||
])
|
||||
self.db.add(Quiz(id=1, title="Bank test", user_id=1, is_published=1, questions_count=6))
|
||||
self.db.flush()
|
||||
# 6 shareable bank questions: 4 cardiology (all under the Kawasaki child), 2 neurology.
|
||||
# 6 bank questions: 4 cardiology (all under the Kawasaki child), 2 neurology.
|
||||
for qid, category in [(1, 2), (2, 2), (3, 2), (4, 2), (5, 10), (6, 10)]:
|
||||
self.db.add(Question(id=qid, question_category_id=category, user_id=3, is_shared=1,
|
||||
self.db.add(Question(id=qid, question_category_id=category, user_id=3,
|
||||
question_text=f"Question {qid}", question_type="mcq",
|
||||
options=["yes", "no"], correct_answer="yes"))
|
||||
self.db.add(Article(id=7, slug="kawasaki", title="Kawasaki disease", sections=[],
|
||||
|
|
|
|||
|
|
@ -83,11 +83,11 @@ class PrivacyTests(unittest.TestCase):
|
|||
self.db.commit()
|
||||
|
||||
def test_tutor_denial_precedes_all_external_boundaries(self):
|
||||
for qid in (4, 5, 999):
|
||||
for qid in (5, 999):
|
||||
self.assertIn(self.chat(qid).status_code, (403, 404))
|
||||
for boundary in (self.quota, self.model, self.similar, self.ai):
|
||||
boundary.assert_not_called()
|
||||
for qid in (1, 3):
|
||||
for qid in (1, 3, 4):
|
||||
res = self.chat(qid)
|
||||
self.assertEqual(res.status_code, 200, res.text)
|
||||
self.assertEqual(self.similar.call_args.args[2].id, 1)
|
||||
|
|
@ -127,8 +127,9 @@ class PrivacyTests(unittest.TestCase):
|
|||
with patch('sqlalchemy.orm.Query.all', autospec=True, side_effect=lambda query: captured.append(query) or []):
|
||||
self.assertEqual(self.find_similar(self.db, q, self.owner), [])
|
||||
sql = str(captured[-1].statement.compile(dialect=postgresql.dialect()))
|
||||
self.assertIn('questions.user_id', sql)
|
||||
self.assertIn('questions.is_shared', sql)
|
||||
# The predicate no longer consults a per-question flag; what it still
|
||||
# excludes is a deleted question and a course's own.
|
||||
self.assertIn('questions.deleted_at IS NULL', sql)
|
||||
self.assertIn('course_id IS NOT NULL', sql)
|
||||
self.assertIn('<=>', sql)
|
||||
self.assertLess(sql.index('WHERE'), sql.index('ORDER BY'))
|
||||
|
|
@ -143,14 +144,14 @@ class PrivacyTests(unittest.TestCase):
|
|||
self.client.headers.clear()
|
||||
self.assertEqual(self.client.get('/uploads/questions/stem-4.png').status_code, 401)
|
||||
self.login(self.owner, cookie=True)
|
||||
for path in ('stem-1', 'stem-3', 'answer-1', 'answer-3'):
|
||||
for path in ('stem-1', 'stem-3', 'stem-4', 'answer-1', 'answer-3'):
|
||||
res = self.client.get(f'/uploads/questions/{path}.png')
|
||||
self.assertEqual(res.status_code, 200, res.text)
|
||||
self.assertEqual(res.headers['cache-control'], 'private, no-store')
|
||||
self.assertEqual(res.headers['vary'], 'Cookie, Authorization')
|
||||
self.assertEqual(self.client.get('/uploads/questions/stem-4.png').status_code, 404)
|
||||
self.assertEqual(self.client.patch('/questions/3/share?shared=1').status_code, 401)
|
||||
self.db.get(Question, 1).is_shared = 0
|
||||
# A course's images stay out of reach whoever asks.
|
||||
self.assertEqual(self.client.get('/uploads/questions/stem-5.png').status_code, 404)
|
||||
self.db.get(Question, 1).deleted_at = datetime(2026, 1, 1)
|
||||
self.db.commit()
|
||||
self.assertEqual(self.client.get('/uploads/questions/stem-1.png').status_code, 404)
|
||||
self.login(self.owner)
|
||||
|
|
@ -236,8 +237,7 @@ class PrivacyTests(unittest.TestCase):
|
|||
self.assertEqual(paths, sorted(set(paths)))
|
||||
self.assertIn('questions/stem-1.png', paths)
|
||||
self.assertIn('questions/stem-4.png', paths) # Now bank-visible via the created shared question.
|
||||
self.assertNotIn('questions/answer-3.png', paths) # Private question images stay private.
|
||||
self.assertNotIn('questions/stem-5.png', paths)
|
||||
self.assertNotIn('questions/stem-5.png', paths) # A course's images stay out of the bank.
|
||||
self.login(self.mod)
|
||||
for field in ('image_path', 'explanation_image_path'):
|
||||
for value in ([], {}, 17, '../escape'):
|
||||
|
|
@ -276,7 +276,7 @@ class PrivacyTests(unittest.TestCase):
|
|||
self.assertEqual(self.client.get('/uploads/legacy-public.svg').status_code, 401)
|
||||
self.login(self.owner)
|
||||
self.assertEqual(self.client.get('/uploads/legacy-public.svg').status_code, 200)
|
||||
self.db.get(Question, 2).is_shared = 0
|
||||
self.db.get(Question, 2).deleted_at = datetime(2026, 1, 1)
|
||||
self.db.commit()
|
||||
self.assertEqual(self.client.get('/uploads/legacy-public.svg').status_code, 404)
|
||||
self.enroll()
|
||||
|
|
@ -353,7 +353,10 @@ class PrivacyTests(unittest.TestCase):
|
|||
self.login(self.owner)
|
||||
self.assertEqual(self.client.get('/uploads/legacy-private.svg?v=1').status_code, 200, value)
|
||||
self.login(self.peer)
|
||||
self.assertEqual(self.client.get('/uploads/legacy-private.svg').status_code, 404, value)
|
||||
# The question is in the bank, so its image is reachable by
|
||||
# anybody who may sit it — which, with per-question sharing
|
||||
# gone, is everybody.
|
||||
self.assertEqual(self.client.get('/uploads/legacy-private.svg').status_code, 200, value)
|
||||
self.login(self.owner)
|
||||
bank_paths = self.client.get('/questions/images').json()
|
||||
self.assertIn({'image_path': 'legacy-private.svg', 'url': '/uploads/legacy-private.svg'}, bank_paths)
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ class SessionLifecycleTests(unittest.TestCase):
|
|||
self.bank.tearDown()
|
||||
|
||||
def timed_quiz(self):
|
||||
quiz_id = self.bank.generate(is_shared=True, category_ids=[1], count=2, mode="timed").json()["id"]
|
||||
quiz_id = self.bank.saved_test([1, 2], is_shared=True, mode="timed")
|
||||
with patch.dict(sys.modules, {"redis": self.redis}):
|
||||
aid = self.client.post(f"/attempts/start?quiz_id={quiz_id}&fresh=true").json()["id"]
|
||||
return quiz_id, aid
|
||||
|
|
@ -224,7 +224,7 @@ class QuizAnalysisTests(unittest.TestCase):
|
|||
self.bank.tearDown()
|
||||
|
||||
def test_a_quiz_nobody_has_sat_answers_with_zeroes_and_every_question_skipped(self):
|
||||
quiz_id = self.bank.generate(is_shared=True, category_ids=[1], count=2).json()["id"]
|
||||
quiz_id = self.bank.saved_test([1, 2], is_shared=True)
|
||||
body = self.client.get(f"/attempts/quiz/{quiz_id}/analysis").json()
|
||||
self.assertTrue(body["not_started"])
|
||||
self.assertIsNone(body["attempt_id"])
|
||||
|
|
@ -238,7 +238,7 @@ class QuizAnalysisTests(unittest.TestCase):
|
|||
self.assertNotIn("recommendations", body)
|
||||
|
||||
def test_once_sat_the_quiz_address_answers_with_the_real_analysis(self):
|
||||
quiz_id = self.bank.generate(is_shared=True, category_ids=[1], count=2).json()["id"]
|
||||
quiz_id = self.bank.saved_test([1, 2], is_shared=True)
|
||||
self.db.add(QuizAttempt(id=910, quiz_id=quiz_id, user_id=self.bank.owner.id, mode="learning",
|
||||
total_questions=1, score=1, started_at=datetime(2026, 5, 1),
|
||||
completed_at=datetime(2026, 5, 1, 1)))
|
||||
|
|
@ -272,7 +272,7 @@ class LiveAnalysisTests(unittest.TestCase):
|
|||
self.bank.tearDown()
|
||||
|
||||
def test_answers_given_but_not_submitted_are_analysed(self):
|
||||
quiz_id = self.bank.generate(is_shared=True, category_ids=[1], count=2).json()["id"]
|
||||
quiz_id = self.bank.saved_test([1, 2], is_shared=True)
|
||||
with patch.dict(sys.modules, {"redis": self.redis}):
|
||||
aid = self.client.post(f"/attempts/start?quiz_id={quiz_id}&mode=study").json()["id"]
|
||||
# One answered, saved to progress; nothing submitted.
|
||||
|
|
@ -299,7 +299,7 @@ class LiveAnalysisTests(unittest.TestCase):
|
|||
whether it was right, and go back and change it — the exam defeated
|
||||
rather than analysed.
|
||||
"""
|
||||
quiz_id = self.bank.generate(is_shared=True, category_ids=[1], count=2).json()["id"]
|
||||
quiz_id = self.bank.saved_test([1, 2], is_shared=True)
|
||||
with patch.dict(sys.modules, {"redis": self.redis}):
|
||||
aid = self.client.post(f"/attempts/start?quiz_id={quiz_id}&mode=exam").json()["id"]
|
||||
self.store[f"quiz_progress:{self.bank.owner.id}:{aid}"] = json.dumps({"answers": {"1": "yes"}})
|
||||
|
|
@ -325,7 +325,7 @@ class LiveAnalysisTests(unittest.TestCase):
|
|||
self.assertEqual(recs.json()["rows"], [])
|
||||
|
||||
def test_a_finished_exam_is_marked(self):
|
||||
quiz_id = self.bank.generate(is_shared=True, category_ids=[1], count=2).json()["id"]
|
||||
quiz_id = self.bank.saved_test([1, 2], is_shared=True)
|
||||
with patch.dict(sys.modules, {"redis": self.redis}):
|
||||
aid = self.client.post(f"/attempts/start?quiz_id={quiz_id}&mode=exam").json()["id"]
|
||||
self.client.post(f"/attempts/{aid}/submit",
|
||||
|
|
@ -338,7 +338,7 @@ class LiveAnalysisTests(unittest.TestCase):
|
|||
self.assertIn("correct", {q["status"] for q in body["questions"]})
|
||||
|
||||
def test_a_live_attempt_with_nothing_answered_reads_as_nothing_answered(self):
|
||||
quiz_id = self.bank.generate(is_shared=True, category_ids=[1], count=2).json()["id"]
|
||||
quiz_id = self.bank.saved_test([1, 2], is_shared=True)
|
||||
with patch.dict(sys.modules, {"redis": self.redis}):
|
||||
aid = self.client.post(f"/attempts/start?quiz_id={quiz_id}&mode=study").json()["id"]
|
||||
body = self.client.get(f"/attempts/{aid}/analysis").json()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Public share-link endpoints: token lifecycle and public landing data."""
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
import test_quiz_builder as fixtures
|
||||
from app.models.quiz import Quiz
|
||||
|
|
@ -44,13 +45,16 @@ class ShareLinkTests(unittest.TestCase):
|
|||
self.assertEqual(self.bank.db.get(Quiz, 1).is_shared, 0)
|
||||
self.assertEqual(self.client.get('/quizzes/1').json()['share_token'], None)
|
||||
|
||||
def test_course_and_private_question_quizzes_cannot_share(self):
|
||||
def test_course_and_removed_question_quizzes_cannot_share(self):
|
||||
self.bank.user = self.bank.mod
|
||||
self.assertEqual(self.client.post('/quizzes/2/share-link').status_code, 400) # Course quiz.
|
||||
mixed = Quiz(title='Private mix', user_id=3, is_published=1, questions_count=1)
|
||||
# A session carrying a question nobody can reach cannot be handed on.
|
||||
# That used to mean one its author kept back; it means a removed one.
|
||||
mixed = Quiz(title='Removed mix', user_id=3, is_published=1, questions_count=1)
|
||||
self.bank.db.add(mixed)
|
||||
self.bank.db.flush()
|
||||
self.bank.db.add(QuizQuestionLink(quiz_id=mixed.id, question_id=4, position=0))
|
||||
self.bank.db.get(fixtures.Question, 4).deleted_at = datetime(2026, 1, 1)
|
||||
self.bank.db.commit()
|
||||
self.assertEqual(self.client.post(f'/quizzes/{mixed.id}/share-link').status_code, 400)
|
||||
mixed.is_shared = 1 # Stale share flag alone must not make it public.
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ class SharedCategoryTests(unittest.TestCase):
|
|||
])
|
||||
self.db.flush()
|
||||
# One of each content type filed under the same tree.
|
||||
self.db.add(Question(id=1, question_category_id=2, user_id=1, is_shared=1,
|
||||
self.db.add(Question(id=1, question_category_id=2, user_id=1,
|
||||
question_text="A question", question_type="mcq",
|
||||
options=["yes", "no"], correct_answer="yes"))
|
||||
self.db.add(Article(id=1, slug="kawasaki", title="Kawasaki disease", sections=[],
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ class StudyToolTests(unittest.TestCase):
|
|||
return response.json()['id']
|
||||
|
||||
def test_attempt_mode_controls_answers_and_stats_not_query_flags(self):
|
||||
quiz_id = self.bank.generate(is_shared=True, category_ids=[1], mode='learning').json()['id']
|
||||
quiz_id = self.bank.saved_test([1, 2], is_shared=True, mode='learning')
|
||||
initial = self.client.get(f'/quizzes/{quiz_id}').json()
|
||||
self.assertNotIn('correct_answer', initial['questions'][0])
|
||||
self.assertEqual(self.client.get(f'/quizzes/{quiz_id}?study=true').status_code, 403)
|
||||
|
|
@ -52,7 +52,7 @@ class StudyToolTests(unittest.TestCase):
|
|||
self.assertEqual(self.client.get(f'/study-tools/attempts/{study}/questions/1/responses').status_code, 404)
|
||||
|
||||
def test_real_response_counts_exclude_skips_expiry_course_and_private_attempts(self):
|
||||
quiz_id = self.bank.generate(is_shared=True, category_ids=[1]).json()['id']
|
||||
quiz_id = self.bank.saved_test([1, 2], is_shared=True)
|
||||
study = self.start(quiz_id, 'study')
|
||||
self.bank.answer(1, True)
|
||||
self.bank.answer(1, True, day=1)
|
||||
|
|
@ -96,7 +96,7 @@ class StudyToolTests(unittest.TestCase):
|
|||
self.assertEqual(self.client.get(f'/quizzes/2?attempt_id={aid}&study=true').status_code, 403)
|
||||
|
||||
def test_progress_outage_returns_failure_not_empty_or_saved_success(self):
|
||||
quiz_id = self.bank.generate(is_shared=True, category_ids=[1]).json()['id']
|
||||
quiz_id = self.bank.saved_test([1, 2], is_shared=True)
|
||||
aid = self.start(quiz_id, 'study')
|
||||
redis = Mock()
|
||||
redis.from_url.return_value.get.side_effect = ConnectionError('Synthetic cache outage')
|
||||
|
|
@ -112,7 +112,7 @@ class StudyToolTests(unittest.TestCase):
|
|||
self.assertEqual(malformed.status_code, 422)
|
||||
|
||||
def test_statistics_match_insensitively_dedupe_and_exclude_obsolete(self):
|
||||
quiz_id = self.bank.generate(is_shared=True, category_ids=[1]).json()['id']
|
||||
quiz_id = self.bank.saved_test([1, 2], is_shared=True)
|
||||
study = self.start(quiz_id, 'study')
|
||||
# Case/whitespace variants of the same option match one bucket.
|
||||
self.bank.answer(1, True)
|
||||
|
|
@ -152,10 +152,10 @@ class StudyToolTests(unittest.TestCase):
|
|||
self.assertEqual(response['sample_size'], 0)
|
||||
|
||||
def test_performance_by_category_expands_links_and_excludes_irrelevant(self):
|
||||
quiz = self.bank.generate(is_shared=True, category_ids=[1]).json()['id']
|
||||
quiz = self.bank.saved_test([1, 2], is_shared=True)
|
||||
self.bank.answer(1, True, quiz_id=quiz)
|
||||
self.bank.answer(1, True, quiz_id=quiz, expired=1) # Expired attempt excluded.
|
||||
quiz2 = self.bank.generate(is_shared=True, category_ids=[2], count=1).json()['id']
|
||||
quiz2 = self.bank.saved_test([2], is_shared=True)
|
||||
self.bank.answer(2, False, quiz_id=quiz2)
|
||||
self.bank.user = self.bank.mod
|
||||
self.client.patch('/questions/3', json={'additional_category_ids': [2]}) # Educators manage questions.
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ class TagHierarchyTests(unittest.TestCase):
|
|||
self.learner = User(id=2, name="Learner", email="l@example.test", hashed_password="unused")
|
||||
self.db.add_all([self.mod, self.learner])
|
||||
for qid in (1, 2, 3):
|
||||
self.db.add(Question(id=qid, user_id=1, is_shared=1, question_text=f"Q{qid}",
|
||||
self.db.add(Question(id=qid, user_id=1, question_text=f"Q{qid}",
|
||||
question_type="mcq", options=["a", "b"], correct_answer="a"))
|
||||
self.db.execute(text("""
|
||||
INSERT INTO question_tags (id, name, type, parent_id, sort_order) VALUES
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export default function PractiseTopic({ article, canEdit, questions, onUnlink })
|
|||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api.get('/questions/builder/count', { params: { article_ids: article.id, state: 'all', is_shared: 'false' } })
|
||||
api.get('/questions/builder/count', { params: { article_ids: article.id, state: 'all' } })
|
||||
.then(res => { if (active) setAvailable(res.data.count) })
|
||||
.catch(() => { if (active) setAvailable(null) })
|
||||
return () => { active = false }
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export default function CustomQuizPage() {
|
|||
const [globalSearch, setGlobalSearch] = useState('')
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
|
||||
const filterKey = JSON.stringify([categoryIds, state, shared, difficulty, articleIds, tagIds, systemIds, refresh])
|
||||
const filterKey = JSON.stringify([categoryIds, state, difficulty, articleIds, tagIds, systemIds, refresh])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
|
@ -78,7 +78,7 @@ export default function CustomQuizPage() {
|
|||
let active = true
|
||||
setAvailable(null)
|
||||
setCountError('')
|
||||
const params = new URLSearchParams({ state, is_shared: String(shared) })
|
||||
const params = new URLSearchParams({ state })
|
||||
if (difficulty) params.append('difficulty', difficulty)
|
||||
categoryIds.forEach(id => params.append('category_ids', id))
|
||||
articleIds.forEach(id => params.append('article_ids', id))
|
||||
|
|
@ -328,7 +328,7 @@ export default function CustomQuizPage() {
|
|||
<div className="custom-test-card-pad">
|
||||
<label className="custom-test-check">
|
||||
<input type="checkbox" checked={shared} onChange={e => setShared(e.target.checked)} />
|
||||
Share with other learners (only shareable questions)
|
||||
Share this session with other learners
|
||||
</label>
|
||||
{mode === 'timed' && (
|
||||
<label className="custom-test-field" style={{ marginTop: 10 }}>
|
||||
|
|
|
|||
|
|
@ -42,9 +42,9 @@ describe('CustomQuizPage', () => {
|
|||
await screen.findByText('30 questions available')
|
||||
expect(screen.getByRole('radio', { name: 'Study mode' })).toBeChecked()
|
||||
// Sharing is our own addition to AMBOSS's criteria, so it lives under More.
|
||||
expect(screen.queryByLabelText(/Share with/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/Share this session/)).not.toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: /^More/ }))
|
||||
expect(screen.getByLabelText(/Share with/)).not.toBeChecked()
|
||||
expect(screen.getByLabelText(/Share this session/)).not.toBeChecked()
|
||||
expect(screen.queryByLabelText(/Time limit/)).not.toBeInTheDocument()
|
||||
|
||||
// Systems and Status are chosen inside their facet pickers, not inline.
|
||||
|
|
@ -60,13 +60,15 @@ describe('CustomQuizPage', () => {
|
|||
await userEvent.click(screen.getByRole('radio', { name: 'Exam mode' }))
|
||||
fireEvent.change(screen.getByLabelText(/Time limit/), { target: { value: '15' } })
|
||||
fireEvent.change(screen.getByLabelText('Number of questions'), { target: { value: '10' } })
|
||||
await userEvent.click(screen.getByLabelText(/Share with/))
|
||||
await userEvent.click(screen.getByLabelText(/Share this session/))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Create Test' })).toBeEnabled())
|
||||
const calls = api.get.mock.calls.filter(([url]) => url === '/questions/builder/count')
|
||||
const params = calls.at(-1)[1].params
|
||||
expect(params.getAll('category_ids')).toEqual(['1', '2'])
|
||||
expect(params.get('state')).toBe('unused')
|
||||
expect(params.get('is_shared')).toBe('true')
|
||||
// The count no longer narrows by sharing: every question in the bank is
|
||||
// shareable, so the session's own flag is not a filter on its contents.
|
||||
expect(params.get('is_shared')).toBeNull()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create Test' }))
|
||||
expect(api.post).toHaveBeenCalledWith('/questions/builder', {
|
||||
title: `Custom test from ${new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}, ${new Date().toLocaleTimeString('en-US', { hour: 'numeric', hour12: true })}`, category_ids: [1, 2], state: 'unused', count: 10,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ const TILES = [
|
|||
{ key: 'category', label: 'Uncategorized', field: 'uncategorized', warn: true },
|
||||
{ key: 'explanation', label: 'No explanation', field: 'no_explanation', warn: true },
|
||||
{ key: 'difficulty', label: 'No difficulty', field: 'no_difficulty', warn: true },
|
||||
{ key: 'private', label: 'Private', field: 'private' },
|
||||
]
|
||||
|
||||
export default function QuestionManagerPage() {
|
||||
|
|
@ -220,7 +219,6 @@ export default function QuestionManagerPage() {
|
|||
? <span className={`qm-pill is-${q.difficulty}`}>{q.difficulty}</span>
|
||||
: <span className="qm-pill is-gap">No difficulty</span>}
|
||||
{!q.explanation && <span className="qm-pill is-gap">No explanation</span>}
|
||||
{q.is_shared === 0 && <span className="qm-pill">Private</span>}
|
||||
{q.quiz_title && q.quiz_id && <span className="qm-pill">{q.quiz_title}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ vi.mock('../components/GrantsPanel', () => ({ default: () => <div data-testid="g
|
|||
|
||||
const SUMMARY = { total: 40, uncategorized: 7, no_explanation: 3, no_difficulty: 12, private: 2, mine: 5 }
|
||||
const QUESTIONS = [
|
||||
{ id: 1, question_text: 'A 3-year-old with fever…', question_category_name: null, difficulty: null, explanation: '', is_shared: 1, quiz_id: null, quiz_title: null },
|
||||
{ id: 2, question_text: 'A neonate with jaundice…', question_category_name: 'Neonatology', difficulty: 'hard', explanation: 'Because…', is_shared: 0, quiz_id: null, quiz_title: null },
|
||||
{ id: 1, question_text: 'A 3-year-old with fever…', question_category_name: null, difficulty: null, explanation: '', quiz_id: null, quiz_title: null },
|
||||
{ id: 2, question_text: 'A neonate with jaundice…', question_category_name: 'Neonatology', difficulty: 'hard', explanation: 'Because…', quiz_id: null, quiz_title: null },
|
||||
]
|
||||
|
||||
const mockApi = (questions = QUESTIONS, total = questions.length) => {
|
||||
|
|
@ -43,7 +43,9 @@ it('shows editorial health counters and flags gaps on each row', async () => {
|
|||
const second = screen.getByText(/neonate with jaundice/).closest('.qm-row')
|
||||
expect(within(second).getByText('Neonatology')).toBeInTheDocument()
|
||||
expect(within(second).getByText('hard')).toBeInTheDocument()
|
||||
expect(within(second).getByText('Private')).toBeInTheDocument()
|
||||
// Per-question sharing is gone: the bank is the bank, and who may manage a
|
||||
// question is the grant tree's business rather than a flag on the row.
|
||||
expect(within(second).queryByText('Private')).toBeNull()
|
||||
})
|
||||
|
||||
it('applies a gap filter from the health tiles', async () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue