From e858f4c166380e89b106ef44410afd018e892d76 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 14:17:55 +0200 Subject: [PATCH] fix: a schema promising an owner where the column now says NULL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening any shared deck answered 500 — ResponseValidationError, "Input should be a valid integer" for user_id. The ownership migration made those columns nullable and the response models still declared `user_id: int`, so the first read of a deck after it was a crash rather than a page. A learner hit it on Cards. FlashcardDeckResponse, DocumentResponse and QuizResponse now allow None, with a test that walks the three and fails if any of them promises an owner again. The grant-input schemas were left alone on purpose: their user_id names the person a grant is for, and a grant with nobody in it is not a thing. Also gone: send_login_code_email, forty-eight lines of email template for a feature that no longer exists. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/routers/flashcards.py | 8 +++-- backend/app/schemas/document.py | 4 ++- backend/app/schemas/quiz.py | 4 ++- backend/app/services/email_service.py | 49 -------------------------- backend/tests/test_question_figures.py | 23 ++++++++++++ 5 files changed, 35 insertions(+), 53 deletions(-) diff --git a/backend/app/routers/flashcards.py b/backend/app/routers/flashcards.py index 1fe79f5..8970082 100644 --- a/backend/app/routers/flashcards.py +++ b/backend/app/routers/flashcards.py @@ -50,7 +50,9 @@ class FlashcardDeckResponse(BaseModel): # them as "Uncategorized" and an educator refiled a deck the system had # already filed correctly. category_id: int | None = None - user_id: int + # Nullable: bank content has no owner, and a schema that promises an + # int where the column says NULL is a 500 on serialisation. + user_id: int | None = None card_count: int is_shared: int = 0 created_at: object @@ -81,7 +83,9 @@ class FlashcardDeckDetail(BaseModel): id: int title: str section_id: int | None = None - user_id: int + # Nullable: bank content has no owner, and a schema that promises an + # int where the column says NULL is a 500 on serialisation. + user_id: int | None = None card_count: int created_at: object cards: list[FlashcardCardResponse] diff --git a/backend/app/schemas/document.py b/backend/app/schemas/document.py index c6b021f..191e0b4 100644 --- a/backend/app/schemas/document.py +++ b/backend/app/schemas/document.py @@ -36,7 +36,9 @@ class SectionResponse(BaseModel): class DocumentResponse(BaseModel): id: int - user_id: int + # Nullable: bank content has no owner, and a schema that promises an + # int where the column says NULL is a 500 on serialisation. + user_id: int | None = None original_filename: str total_pages: int | None status: str diff --git a/backend/app/schemas/quiz.py b/backend/app/schemas/quiz.py index c376b65..94e9f66 100644 --- a/backend/app/schemas/quiz.py +++ b/backend/app/schemas/quiz.py @@ -53,7 +53,9 @@ class QuestionWithAnswer(QuestionResponse): class QuizResponse(BaseModel): id: int section_id: int | None = None - user_id: int + # Nullable: bank content has no owner, and a schema that promises an + # int where the column says NULL is a 500 on serialisation. + user_id: int | None = None title: str questions_count: int mode: str diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index e4aebd5..c8edfba 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -167,52 +167,3 @@ We received a request to reset your password. Click below to choose a new one. [link:Or copy this link]({url}) """ await _send(to_email, subject, _wrap(subject, md)) - - -async def send_login_code_email(to_email: str, name: str, code: str): - """The code, and as little else as possible. - - One thing is being asked of the reader — read six characters and type them - — so the code is the biggest thing on the screen and everything else is - underneath it in the order it matters: which account this signs into, a way - back to the page, and permission to ignore the whole thing. - - Written as its own centred block rather than through the markdown renderer: - the renderer lays out prose left to right, which is right for every other - message we send and wrong for this one. - """ - #: The code arrives here already grouped for reading; any shape it is typed - #: back in is normalised before it is compared. - subject = "Sign in to PedsHub" - body = f""" -
-

- Sign in to PedsHub -

- -
- {code} -
- -

- Enter this code to sign in as
- {to_email} -

- -

- - Sign in to PedsHub - -

- -

- The code works once and expires in 15 minutes.
- If you didn't try to log in, you can ignore this email. -

-
-""" - await _send(to_email, subject, _wrap_html(subject, body, footer_ignore=False)) diff --git a/backend/tests/test_question_figures.py b/backend/tests/test_question_figures.py index 2f3bc61..3e0df0a 100644 --- a/backend/tests/test_question_figures.py +++ b/backend/tests/test_question_figures.py @@ -68,3 +68,26 @@ class FigureCaptionTests(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class OwnerlessSerialisationTests(unittest.TestCase): + """A schema that promises an owner where the column says NULL is a 500. + + Every deck, category, document, media library and shared quiz is ownerless + now. The response models still declared `user_id: int`, so the first read + of a shared deck after the migration answered + ResponseValidationError — a crash, not a refusal, on a page any learner + opens. + """ + + def test_every_bank_schema_allows_no_owner(self): + from app.routers.flashcards import FlashcardDeckResponse + from app.schemas.document import DocumentResponse + from app.schemas.quiz import QuizResponse + + for model in (FlashcardDeckResponse, DocumentResponse, QuizResponse): + field = model.model_fields.get("user_id") + self.assertIsNotNone(field, model.__name__) + self.assertTrue( + type(None) in getattr(field.annotation, "__args__", ()), + f"{model.__name__}.user_id must allow None")