fix: a schema promising an owner where the column now says NULL
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:
parent
1f2770257f
commit
e858f4c166
5 changed files with 35 additions and 53 deletions
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue