Add comprehensive Pydantic models and configure templates/static files

- Create detailed Pydantic models for anime requests and responses
- Add AnimeCreateRequest, AnimeUpdateRequest, PaginatedAnimeResponse, etc.
- Update route signatures to use proper response models
- Convert return values to use Pydantic models instead of raw dicts
- Configure Jinja2Templates in FastAPI application
- Mount StaticFiles for CSS, JS, images at /static endpoint
- Update anime search and list endpoints to use typed responses
- Mark completed Pydantic models and template configuration tasks in web_todo.md
This commit is contained in:
Lukas Pupka-Lipinski 2025-10-05 23:10:11 +02:00
parent e15c0a21e0
commit 6e136e832b
3 changed files with 98 additions and 55 deletions

View File

@ -27,6 +27,8 @@ from fastapi import FastAPI, HTTPException, Depends, Security, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings
import uvicorn
@ -228,6 +230,12 @@ app = FastAPI(
lifespan=lifespan
)
# Configure templates
templates = Jinja2Templates(directory="src/server/web/templates")
# Mount static files
app.mount("/static", StaticFiles(directory="src/server/web/static"), name="static")
# Add CORS middleware
app.add_middleware(
CORSMiddleware,

View File

@ -33,6 +33,43 @@ class AnimeResponse(BaseModel):
folder: Optional[str] = None
episodes: int = 0
class AnimeCreateRequest(BaseModel):
"""Request model for creating anime entries."""
name: str = Field(..., min_length=1, max_length=255)
folder: str = Field(..., min_length=1)
description: Optional[str] = None
status: str = Field(default="planned", pattern="^(ongoing|completed|planned|dropped|paused)$")
genre: Optional[str] = None
year: Optional[int] = Field(None, ge=1900, le=2100)
class AnimeUpdateRequest(BaseModel):
"""Request model for updating anime entries."""
name: Optional[str] = Field(None, min_length=1, max_length=255)
folder: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = Field(None, pattern="^(ongoing|completed|planned|dropped|paused)$")
genre: Optional[str] = None
year: Optional[int] = Field(None, ge=1900, le=2100)
class PaginatedAnimeResponse(BaseModel):
"""Paginated response model for anime lists."""
success: bool = True
data: List[AnimeResponse]
pagination: Dict[str, Any]
class AnimeSearchResponse(BaseModel):
"""Response model for anime search results."""
success: bool = True
data: List[AnimeResponse]
pagination: Dict[str, Any]
search: Dict[str, Any]
class RescanResponse(BaseModel):
"""Response model for rescan operations."""
success: bool
message: str
total_series: int
# Dependency to get SeriesApp instance
def get_series_app() -> SeriesApp:
"""Get SeriesApp instance for business logic operations."""
@ -47,7 +84,7 @@ def get_series_app() -> SeriesApp:
router = APIRouter(prefix='/api/v1/anime', tags=['anime'])
@router.get('', response_model=Dict[str, Any])
@router.get('', response_model=PaginatedAnimeResponse)
async def list_anime(
status: Optional[str] = Query(None, pattern="^(ongoing|completed|planned|dropped|paused)$"),
genre: Optional[str] = Query(None),
@ -57,7 +94,7 @@ async def list_anime(
per_page: int = Query(50, ge=1, le=1000),
current_user: Optional[Dict] = Depends(get_current_user),
series_app: SeriesApp = Depends(get_series_app)
) -> Dict[str, Any]:
) -> PaginatedAnimeResponse:
"""
Get all anime with optional filtering and pagination.
@ -76,35 +113,34 @@ async def list_anime(
# Get the series list from SeriesApp
anime_list = series_app.series_list
# Convert to list of dicts for response
formatted_anime = []
# Convert to list of AnimeResponse objects
anime_responses = []
for series_item in anime_list:
anime_dict = {
'id': getattr(series_item, 'id', str(uuid.uuid4())),
'title': getattr(series_item, 'name', 'Unknown'),
'folder': getattr(series_item, 'folder', ''),
'description': getattr(series_item, 'description', ''),
'status': 'ongoing', # Default status
'episodes': getattr(series_item, 'total_episodes', 0)
}
anime_response = AnimeResponse(
id=getattr(series_item, 'id', str(uuid.uuid4())),
title=getattr(series_item, 'name', 'Unknown'),
folder=getattr(series_item, 'folder', ''),
description=getattr(series_item, 'description', ''),
status='ongoing', # Default status
episodes=getattr(series_item, 'total_episodes', 0)
)
# Apply search filter if provided
if search:
if search.lower() not in anime_dict['title'].lower():
if search.lower() not in anime_response.title.lower():
continue
formatted_anime.append(anime_dict)
anime_responses.append(anime_response)
# Apply pagination
total = len(formatted_anime)
total = len(anime_responses)
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
paginated_anime = formatted_anime[start_idx:end_idx]
paginated_anime = anime_responses[start_idx:end_idx]
return {
"success": True,
"data": paginated_anime,
"pagination": {
return PaginatedAnimeResponse(
data=paginated_anime,
pagination={
"page": page,
"per_page": per_page,
"total": total,
@ -112,7 +148,7 @@ async def list_anime(
"has_next": end_idx < total,
"has_prev": page > 1
}
}
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@ -370,14 +406,14 @@ def delete_anime(anime_id: int) -> Dict[str, Any]:
)
@router.get('/search', response_model=Dict[str, Any])
@router.get('/search', response_model=AnimeSearchResponse)
async def search_anime(
q: str = Query(..., min_length=2, description="Search query"),
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
current_user: Optional[Dict] = Depends(get_current_user),
series_app: SeriesApp = Depends(get_series_app)
) -> Dict[str, Any]:
) -> AnimeSearchResponse:
"""
Search anime by name using SeriesApp.
@ -393,29 +429,28 @@ async def search_anime(
# Use SeriesApp to perform search
search_results = series_app.search(q)
# Convert search results to our response format
formatted_results = []
# Convert search results to AnimeResponse objects
anime_responses = []
for result in search_results:
anime_dict = {
'id': getattr(result, 'id', str(uuid.uuid4())),
'title': getattr(result, 'name', getattr(result, 'title', 'Unknown')),
'description': getattr(result, 'description', ''),
'status': 'available',
'episodes': getattr(result, 'episodes', 0),
'key': getattr(result, 'key', '')
}
formatted_results.append(anime_dict)
anime_response = AnimeResponse(
id=getattr(result, 'id', str(uuid.uuid4())),
title=getattr(result, 'name', getattr(result, 'title', 'Unknown')),
description=getattr(result, 'description', ''),
status='available',
episodes=getattr(result, 'episodes', 0),
folder=getattr(result, 'key', '')
)
anime_responses.append(anime_response)
# Apply pagination
total = len(formatted_results)
total = len(anime_responses)
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
paginated_results = formatted_results[start_idx:end_idx]
paginated_results = anime_responses[start_idx:end_idx]
return {
"success": True,
"data": paginated_results,
"pagination": {
return AnimeSearchResponse(
data=paginated_results,
pagination={
"page": page,
"per_page": per_page,
"total": total,
@ -423,11 +458,11 @@ async def search_anime(
"has_next": end_idx < total,
"has_prev": page > 1
},
"search": {
search={
"query": q,
"total_results": total
}
}
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@ -633,11 +668,11 @@ def bulk_anime_operation() -> Dict[str, Any]:
message=f"Bulk {action} operation completed"
)
@router.post('/rescan', response_model=Dict[str, Any])
@router.post('/rescan', response_model=RescanResponse)
async def rescan_anime_directory(
current_user: Dict = Depends(get_current_user),
series_app: SeriesApp = Depends(get_series_app)
) -> Dict[str, Any]:
) -> RescanResponse:
"""
Rescan the anime directory for new episodes and series.
@ -653,11 +688,11 @@ async def rescan_anime_directory(
series_app.ReScan(progress_callback)
return {
"success": True,
"message": "Anime directory rescanned successfully",
"total_series": len(series_app.series_list) if hasattr(series_app, 'series_list') else 0
}
return RescanResponse(
success=True,
message="Anime directory rescanned successfully",
total_series=len(series_app.series_list) if hasattr(series_app, 'series_list') else 0
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,

View File

@ -35,17 +35,17 @@ This document contains tasks for migrating the web application from Flask to Fas
- [x] Convert Flask `redirect()` and `url_for()` to FastAPI equivalents
### Request/Response Models
- [ ] Create Pydantic models for request bodies (replace Flask request parsing)
- [ ] Create Pydantic models for response schemas
- [x] Create Pydantic models for request bodies (replace Flask request parsing)
- [x] Create Pydantic models for response schemas
- [ ] Update form handling to use FastAPI Form dependencies
- [ ] Convert file upload handling to FastAPI UploadFile
## 🎨 Template and Static Files Migration
### Template Engine Setup
- [ ] Configure Jinja2Templates in FastAPI application
- [ ] Set up template directory structure
- [ ] Create templates directory configuration in FastAPI app
- [x] Configure Jinja2Templates in FastAPI application
- [x] Set up template directory structure
- [x] Create templates directory configuration in FastAPI app
### HTML Template Migration
- [ ] Review all `.html` files in templates directory
@ -55,7 +55,7 @@ This document contains tasks for migrating the web application from Flask to Fas
- [ ] Test all template variables and filters still work correctly
### Static Files Configuration
- [ ] Configure StaticFiles mount in FastAPI for CSS, JS, images
- [x] Configure StaticFiles mount in FastAPI for CSS, JS, images
- [ ] Update static file URL generation in templates
- [ ] Verify all CSS file references work correctly
- [ ] Verify all JavaScript file references work correctly