refactor(api): Pydantic request models for channels routes (R6 S1b)

update_channel: payload:dict → ChannelUpdateIn (priority/hidden/deep_requested,
all optional for PATCH semantics). Kills the `int(payload["priority"])` 500 the
prod report hit — a non-numeric/garbage value now returns 422, not 500.
attach_tag: payload:dict → AttachTagIn(tag_id:int) — a non-int tag_id was a 500
in `db.get(Tag, ...)`; the personal-tag ownership check reuses owned_or_404.
This commit is contained in:
2026-07-26 04:46:40 +02:00
parent 9fe050279e
commit d982e8b9ae
+29 -16
View File
@@ -5,6 +5,7 @@ import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -24,6 +25,7 @@ from app.models import (
User, User,
Video, Video,
) )
from app.routes._common import owned_or_404
from app.routes.admin import admin_user from app.routes.admin import admin_user
from app.sync.explore import explore_ingest_page from app.sync.explore import explore_ingest_page
from app.sync.runner import run_recent_backfill from app.sync.runner import run_recent_backfill
@@ -378,28 +380,37 @@ def _is_blocked(db: Session, user: User, channel_id: str) -> bool:
) )
class ChannelUpdateIn(BaseModel):
# PATCH semantics: a field left unset (None) is untouched. Every field is optional so the
# UI can send just the one control it changed. int/bool coercion + rejection of garbage
# (a non-numeric priority, a non-bool flag) is now Pydantic's job → 422, not a 500.
priority: int | None = None
hidden: bool | None = None
deep_requested: bool | None = None
@router.patch("/{channel_id}") @router.patch("/{channel_id}")
def update_channel( def update_channel(
channel_id: str, channel_id: str,
payload: dict, body: ChannelUpdateIn,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
sub = _user_subscription(db, user, channel_id) sub = _user_subscription(db, user, channel_id)
channel = db.get(Channel, channel_id) channel = db.get(Channel, channel_id)
deep_turned_on = False deep_turned_on = False
if "priority" in payload: if body.priority is not None:
sub.priority = int(payload["priority"]) sub.priority = body.priority
if "hidden" in payload: if body.hidden is not None:
sub.hidden = bool(payload["hidden"]) sub.hidden = body.hidden
# The demo account is SHARED, so it must not persist deep_requested (a quota-relevant flag) onto # The demo account is SHARED, so it must not persist deep_requested (a quota-relevant flag) onto
# the shared subscription — skip the write entirely for demo, which also keeps deep_turned_on # the shared subscription — skip the write entirely for demo, which also keeps deep_turned_on
# False so the immediate backfill below can never fire for it. # False so the immediate backfill below can never fire for it.
if "deep_requested" in payload and not user.is_demo: if body.deep_requested is not None and not user.is_demo:
# Opt this channel into (or out of) full-history backfill. The deep scheduler # Opt this channel into (or out of) full-history backfill. The deep scheduler
# picks the flag up on its next run; turning it off won't undo already-fetched # picks the flag up on its next run; turning it off won't undo already-fetched
# videos, it just stops further deep paging if nobody else still wants it. # videos, it just stops further deep paging if nobody else still wants it.
want = bool(payload["deep_requested"]) want = body.deep_requested
deep_turned_on = want and not sub.deep_requested deep_turned_on = want and not sub.deep_requested
sub.deep_requested = want sub.deep_requested = want
db.commit() db.commit()
@@ -601,23 +612,25 @@ def unblock_channel(
return {"unblocked": channel_id} return {"unblocked": channel_id}
class AttachTagIn(BaseModel):
tag_id: int
@router.post("/{channel_id}/tags") @router.post("/{channel_id}/tags")
def attach_tag( def attach_tag(
channel_id: str, channel_id: str,
payload: dict, body: AttachTagIn,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
_user_subscription(db, user, channel_id) _user_subscription(db, user, channel_id)
tag_id = payload.get("tag_id") # Only the user's own tags may be attached as personal links (owned_or_404 → 404 if the tag
tag = db.get(Tag, tag_id) if tag_id is not None else None # is missing OR belongs to someone else, without leaking which).
# Only the user's own tags may be attached as personal links. owned_or_404(db, Tag, body.tag_id, user, detail="Unknown tag")
if tag is None or tag.user_id != user.id:
raise HTTPException(status_code=404, detail="Unknown tag")
exists = db.execute( exists = db.execute(
select(ChannelTag).where( select(ChannelTag).where(
ChannelTag.channel_id == channel_id, ChannelTag.channel_id == channel_id,
ChannelTag.tag_id == tag_id, ChannelTag.tag_id == body.tag_id,
ChannelTag.user_id == user.id, ChannelTag.user_id == user.id,
) )
).scalar_one_or_none() ).scalar_one_or_none()
@@ -625,13 +638,13 @@ def attach_tag(
db.add( db.add(
ChannelTag( ChannelTag(
channel_id=channel_id, channel_id=channel_id,
tag_id=tag_id, tag_id=body.tag_id,
user_id=user.id, user_id=user.id,
source="user", source="user",
) )
) )
db.commit() db.commit()
return {"channel_id": channel_id, "tag_id": tag_id} return {"channel_id": channel_id, "tag_id": body.tag_id}
@router.delete("/{channel_id}/tags/{tag_id}") @router.delete("/{channel_id}/tags/{tag_id}")