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
|
||||
# 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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
<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__":
|
||||
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