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 fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func, select, update
from sqlalchemy.orm import Session
@@ -24,6 +25,7 @@ from app.models import (
User,
Video,
)
from app.routes._common import owned_or_404
from app.routes.admin import admin_user
from app.sync.explore import explore_ingest_page
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}")
def update_channel(
channel_id: str,
payload: dict,
body: ChannelUpdateIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
sub = _user_subscription(db, user, channel_id)
channel = db.get(Channel, channel_id)
deep_turned_on = False
if "priority" in payload:
sub.priority = int(payload["priority"])
if "hidden" in payload:
sub.hidden = bool(payload["hidden"])
if body.priority is not None:
sub.priority = body.priority
if body.hidden is not None:
sub.hidden = body.hidden
# 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
# 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
# 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.
want = bool(payload["deep_requested"])
want = body.deep_requested
deep_turned_on = want and not sub.deep_requested
sub.deep_requested = want
db.commit()
@@ -601,23 +612,25 @@ def unblock_channel(
return {"unblocked": channel_id}
class AttachTagIn(BaseModel):
tag_id: int
@router.post("/{channel_id}/tags")
def attach_tag(
channel_id: str,
payload: dict,
body: AttachTagIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
_user_subscription(db, user, channel_id)
tag_id = payload.get("tag_id")
tag = db.get(Tag, tag_id) if tag_id is not None else None
# Only the user's own tags may be attached as personal links.
if tag is None or tag.user_id != user.id:
raise HTTPException(status_code=404, detail="Unknown tag")
# Only the user's own tags may be attached as personal links (owned_or_404 → 404 if the tag
# is missing OR belongs to someone else, without leaking which).
owned_or_404(db, Tag, body.tag_id, user, detail="Unknown tag")
exists = db.execute(
select(ChannelTag).where(
ChannelTag.channel_id == channel_id,
ChannelTag.tag_id == tag_id,
ChannelTag.tag_id == body.tag_id,
ChannelTag.user_id == user.id,
)
).scalar_one_or_none()
@@ -625,13 +638,13 @@ def attach_tag(
db.add(
ChannelTag(
channel_id=channel_id,
tag_id=tag_id,
tag_id=body.tag_id,
user_id=user.id,
source="user",
)
)
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}")