fix: a schema promising an owner where the column now says NULL
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Successful in 29s
Tests / e2e (push) Failing after 33s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-13 14:17:55 +02:00
parent 1f2770257f
commit e858f4c166
5 changed files with 35 additions and 53 deletions

View file

@ -50,7 +50,9 @@ class FlashcardDeckResponse(BaseModel):
# them as "Uncategorized" and an educator refiled a deck the system had # them as "Uncategorized" and an educator refiled a deck the system had
# already filed correctly. # already filed correctly.
category_id: int | None = None 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 card_count: int
is_shared: int = 0 is_shared: int = 0
created_at: object created_at: object
@ -81,7 +83,9 @@ class FlashcardDeckDetail(BaseModel):
id: int id: int
title: str title: str
section_id: int | None = None 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 card_count: int
created_at: object created_at: object
cards: list[FlashcardCardResponse] cards: list[FlashcardCardResponse]

View file

@ -36,7 +36,9 @@ class SectionResponse(BaseModel):
class DocumentResponse(BaseModel): class DocumentResponse(BaseModel):
id: int 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 original_filename: str
total_pages: int | None total_pages: int | None
status: str status: str

View file

@ -53,7 +53,9 @@ class QuestionWithAnswer(QuestionResponse):
class QuizResponse(BaseModel): class QuizResponse(BaseModel):
id: int id: int
section_id: int | None = None 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 title: str
questions_count: int questions_count: int
mode: str mode: str

View file

@ -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}) [link:Or copy this link]({url})
""" """
await _send(to_email, subject, _wrap(subject, md)) 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"""
<div style="text-align:center;">
<h1 style="margin:0 0 28px;font-size:26px;font-weight:600;color:#09090b;letter-spacing:-0.3px;">
Sign in to PedsHub
</h1>
<div style="display:inline-block;background:#f8fafc;border:1px solid #2563eb;border-radius:10px;
padding:18px 30px;margin-bottom:22px;">
<span style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:30px;
font-weight:700;letter-spacing:0.30em;color:#2563eb;">{code}</span>
</div>
<p style="margin:0 0 26px;font-size:15px;color:#3f3f46;line-height:1.6;">
Enter this code to sign in as<br/>
<a href="mailto:{to_email}" style="color:#2563eb;font-weight:600;text-decoration:underline;">{to_email}</a>
</p>
<p style="margin:0 0 26px;">
<a href="{settings.APP_URL}/login"
style="display:inline-block;border:1px solid #2563eb;border-radius:8px;padding:12px 28px;
font-size:15px;color:#2563eb;text-decoration:none;letter-spacing:0.02em;">
Sign in to PedsHub
</a>
</p>
<p style="margin:0;font-size:13px;color:#a1a1aa;line-height:1.6;">
The code works once and expires in 15 minutes.<br/>
If you didn't try to log in, you can ignore this email.
</p>
</div>
"""
await _send(to_email, subject, _wrap_html(subject, body, footer_ignore=False))

View file

@ -68,3 +68,26 @@ class FigureCaptionTests(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.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")