Initial commit: RSS platform phase 1 skeleton with code review fixes
Features: - FastAPI + SQLAlchemy 2.0 async + PostgreSQL/pgvector + Redis backend - Vue 3 + TypeScript + Element Plus frontend - JWT auth with access/refresh tokens and revocation - Admin/member RBAC - RSS feed CRUD and article listing - Settings management with Fernet encryption for sensitive values - Redis distributed lock service - Alembic initial migration - Docker Compose development environment Fixes from code review: - Fix DB session leak in dependency injection - Restrict registration to admin only - Add default admin password warning - Implement JWT refresh tokens and jti blacklist - Strengthen password policy - Use func.count for pagination totals - Replace NullPool with AsyncAdaptedQueuePool - Remove init_db from lifespan to enforce alembic migrations - Add request_id middleware and logging filter - Fix vite.config.ts env loading - Add frontend token refresh interceptor - Add Vue error handler Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
"""Feeds router."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.models.feed import Feed
|
||||
from app.models.user import User
|
||||
from app.schemas.common import MessageResponse, PaginatedResponse, PaginationParams
|
||||
from app.schemas.feed import FeedCreate, FeedOut, FeedUpdate
|
||||
|
||||
router = APIRouter(prefix="/feeds", tags=["feeds"])
|
||||
|
||||
|
||||
@router.get("", response_model=PaginatedResponse)
|
||||
async def list_feeds(
|
||||
pagination: PaginationParams = Depends(),
|
||||
category: str | None = Query(None),
|
||||
search: str | None = Query(None),
|
||||
is_active: bool | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List RSS feeds with pagination and filters."""
|
||||
query = select(Feed)
|
||||
|
||||
if category:
|
||||
query = query.where(Feed.category == category)
|
||||
if search:
|
||||
query = query.where(
|
||||
Feed.title.ilike(f"%{search}%")
|
||||
| Feed.url.ilike(f"%{search}%")
|
||||
| Feed.description.ilike(f"%{search}%")
|
||||
)
|
||||
if is_active is not None:
|
||||
query = query.where(Feed.is_active == is_active)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(count_query)).scalar_one()
|
||||
|
||||
# Get paginated items
|
||||
query = query.offset(pagination.skip).limit(pagination.limit).order_by(Feed.created_at.desc())
|
||||
result = await db.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"items": [FeedOut.model_validate(item) for item in items],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{feed_id}", response_model=FeedOut)
|
||||
async def get_feed(
|
||||
feed_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get a single feed by ID."""
|
||||
feed = await db.get(Feed, feed_id)
|
||||
if not feed:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feed not found")
|
||||
return FeedOut.model_validate(feed)
|
||||
|
||||
|
||||
@router.post("", response_model=FeedOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_feed(
|
||||
feed_in: FeedCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new RSS feed."""
|
||||
# Check URL uniqueness
|
||||
result = await db.execute(select(Feed).where(Feed.url == str(feed_in.url)))
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Feed with this URL already exists",
|
||||
)
|
||||
|
||||
feed = Feed(
|
||||
url=str(feed_in.url),
|
||||
title=feed_in.title or "",
|
||||
description=feed_in.description or "",
|
||||
category=feed_in.category or "",
|
||||
is_active=feed_in.is_active,
|
||||
fetch_interval_minutes=feed_in.fetch_interval_minutes,
|
||||
priority=feed_in.priority,
|
||||
parser_config=feed_in.parser_config,
|
||||
proxy_policy=feed_in.proxy_policy,
|
||||
)
|
||||
db.add(feed)
|
||||
await db.commit()
|
||||
await db.refresh(feed)
|
||||
return FeedOut.model_validate(feed)
|
||||
|
||||
|
||||
@router.put("/{feed_id}", response_model=FeedOut)
|
||||
async def update_feed(
|
||||
feed_id: str,
|
||||
feed_in: FeedUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update an existing feed."""
|
||||
feed = await db.get(Feed, feed_id)
|
||||
if not feed:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feed not found")
|
||||
|
||||
update_data = feed_in.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if field == "url" and value is not None:
|
||||
value = str(value)
|
||||
setattr(feed, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(feed)
|
||||
return FeedOut.model_validate(feed)
|
||||
|
||||
|
||||
@router.delete("/{feed_id}", response_model=MessageResponse)
|
||||
async def delete_feed(
|
||||
feed_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a feed."""
|
||||
feed = await db.get(Feed, feed_id)
|
||||
if not feed:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feed not found")
|
||||
|
||||
await db.delete(feed)
|
||||
await db.commit()
|
||||
return {"message": "Feed deleted successfully"}
|
||||
Reference in New Issue
Block a user