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:
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user