Initial commit.
This commit is contained in:
commit
2fa935016e
11 changed files with 1410 additions and 0 deletions
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
17
app/database.py
Normal file
17
app/database.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
DATABASE_PATH = Path(__file__).parent.parent / "data" / "reviews.db"
|
||||
|
||||
|
||||
def get_db():
|
||||
"""
|
||||
FastAPI dependency that provides a database connection.
|
||||
Yields a connection, then closes it when the request is done.
|
||||
"""
|
||||
conn = sqlite3.connect(DATABASE_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
76
app/main.py
Normal file
76
app/main.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
from fastapi import FastAPI
|
||||
from scalar_fastapi import get_scalar_api_reference
|
||||
|
||||
from app.routes import discovery, reviews, stats
|
||||
|
||||
DESCRIPTION = """
|
||||
Hi, this is a read-only API exposing 20+ years of my personal media reviews of movies, books,
|
||||
and television since 2001.
|
||||
|
||||
## What this API does
|
||||
|
||||
This API provides structured access to a personal media review database. Every
|
||||
review includes a date, category, title, creator, genre, rating, and optional
|
||||
written commentary.
|
||||
|
||||
## Rating scale
|
||||
|
||||
Ratings use a **-3.0 to 3.0** scale:
|
||||
|
||||
| Rating | Meaning |
|
||||
|--------|---------|
|
||||
| 3.0 | Masterpiece, tears flowed |
|
||||
| 2.0 | Great, I was glowing for a day |
|
||||
| 1.0 | Good enough use of my time |
|
||||
| 0.0 | Meh, a void of space |
|
||||
| -1.0 | Below average, should have spent this time knitting |
|
||||
| -2.0 | Bad, I want someone to pay me for this |
|
||||
| -3.0 | Terrible, I felt like I needed to use toilet paper after |
|
||||
|
||||
## Getting started
|
||||
|
||||
- Browse available categories, genres, and creators via the **Discovery**
|
||||
endpoints
|
||||
- Fetch reviews with filtering and pagination via **GET /reviews**
|
||||
- Explore aggregate statistics via the **Statistics** endpoints
|
||||
- Get a surprise recommendation via **GET /reviews/random**
|
||||
|
||||
## About
|
||||
|
||||
This API is a portfolio project demonstrating API design and documentation
|
||||
skills. Built with [FastAPI](https://fastapi.tiangolo.com/) and documented
|
||||
with [Scalar](https://scalar.com/).
|
||||
"""
|
||||
|
||||
CUSTOM_CSS="""
|
||||
.darklight-reference-promo { display: none !important; }
|
||||
"""
|
||||
|
||||
app = FastAPI(
|
||||
title="Reviews API",
|
||||
description=DESCRIPTION,
|
||||
version="1.0.0",
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/docs", include_in_schema=False)
|
||||
async def scalar_docs():
|
||||
return get_scalar_api_reference(
|
||||
openapi_url=app.openapi_url,
|
||||
title=app.title,
|
||||
force_dark_mode_state="light",
|
||||
hide_dark_mode_toggle=True,
|
||||
default_open_all_tags=False,
|
||||
custom_css=CUSTOM_CSS,
|
||||
)
|
||||
|
||||
app.include_router(reviews.router)
|
||||
app.include_router(discovery.router)
|
||||
app.include_router(stats.router)
|
||||
|
||||
@app.get("/health", tags=["System"])
|
||||
def health_check():
|
||||
"""Verify the API is running."""
|
||||
return {"status": "ok"}
|
||||
175
app/models.py
Normal file
175
app/models.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Core review models
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class Review(BaseModel):
|
||||
"""A single review of a movie, book, or television show."""
|
||||
|
||||
date: str = Field(
|
||||
...,
|
||||
description="Date the review was recorded, in YYYY-MM-DD format.",
|
||||
examples=["2025-02-06"],
|
||||
)
|
||||
unixTime: int = Field(
|
||||
...,
|
||||
description="Unix timestamp corresponding to the review date.",
|
||||
examples=[1738796400],
|
||||
)
|
||||
category: Optional[str] = Field(
|
||||
None,
|
||||
description="Media type: Movies, Books, or Television.",
|
||||
examples=["Movies"],
|
||||
)
|
||||
title: Optional[str] = Field(
|
||||
None,
|
||||
description="Title of the work being reviewed.",
|
||||
examples=["The Brutalist"],
|
||||
)
|
||||
creator: Optional[str] = Field(
|
||||
None,
|
||||
description="Director, author, or showrunner of the work.",
|
||||
examples=["Brady Corbet"],
|
||||
)
|
||||
genre: Optional[str] = Field(
|
||||
None,
|
||||
description="Genre classification of the work.",
|
||||
examples=["Drama"],
|
||||
)
|
||||
rating: Optional[float] = Field(
|
||||
None,
|
||||
description="Rating on a scale from -3.0 (worst) to 3.0 (best). A rating of 0.0 indicates a neutral opinion (it was meh).",
|
||||
examples=[3.0],
|
||||
)
|
||||
review: Optional[str] = Field(
|
||||
None,
|
||||
description="Free-text review. Empty string or '.' indicates no written review.",
|
||||
examples=[
|
||||
"A wonderfully expansive film about the immigrant experience."
|
||||
],
|
||||
)
|
||||
|
||||
year: Optional[int] = Field(
|
||||
None,
|
||||
description="Release or publication year of the work.",
|
||||
examples=[2024],
|
||||
)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ReviewListResponse(BaseModel):
|
||||
"""Paginated response wrapper for a list of reviews."""
|
||||
|
||||
total: int = Field(
|
||||
...,
|
||||
description="Total number of reviews matching the query filters.",
|
||||
examples=[642],
|
||||
)
|
||||
limit: int = Field(
|
||||
...,
|
||||
description="Maximum number of reviews returned in this response.",
|
||||
examples=[20],
|
||||
)
|
||||
offset: int = Field(
|
||||
...,
|
||||
description="Number of reviews skipped from the start of the result set.",
|
||||
examples=[0],
|
||||
)
|
||||
results: list[Review] = Field(
|
||||
...,
|
||||
description="The reviews for the current page.",
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Discovery models
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class CategoryCount(BaseModel):
|
||||
"""A category with its review count."""
|
||||
|
||||
category: str = Field(..., description="The media category name.", examples=["Movies"])
|
||||
count: int = Field(..., description="Number of reviews in this category.", examples=[412])
|
||||
|
||||
|
||||
class GenreCount(BaseModel):
|
||||
"""A genre with its review count."""
|
||||
|
||||
genre: str = Field(..., description="The genre name.", examples=["Science Fiction"])
|
||||
count: int = Field(..., description="Number of reviews in this genre.", examples=[58])
|
||||
|
||||
|
||||
class CreatorCount(BaseModel):
|
||||
"""A creator with their review count."""
|
||||
|
||||
creator: str = Field(..., description="Name of the director, author, or showrunner.", examples=["Steven Spielberg"])
|
||||
count: int = Field(..., description="Number of works by this creator that have been reviewed.", examples=[7])
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Stats models
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class CategoryBreakdown(BaseModel):
|
||||
"""Review count for a single category."""
|
||||
|
||||
category: str = Field(..., examples=["Movies"])
|
||||
count: int = Field(..., examples=[412])
|
||||
|
||||
|
||||
class OverviewStats(BaseModel):
|
||||
"""High-level statistics across all reviews."""
|
||||
|
||||
total_reviews: int = Field(..., description="Total number of reviews in the database.", examples=[642])
|
||||
categories: list[CategoryBreakdown] = Field(..., description="Number of reviews per category.")
|
||||
average_rating: float = Field(..., description="Mean rating across all reviews.", examples=[1.02])
|
||||
earliest_review: str = Field(..., description="Date of the oldest review.", examples=["2001-06-29"])
|
||||
latest_review: str = Field(..., description="Date of the most recent review.", examples=["2025-02-09"])
|
||||
|
||||
|
||||
class CreatorStats(BaseModel):
|
||||
"""A creator ranked by number of reviewed works."""
|
||||
|
||||
creator: str = Field(..., description="Name of the creator.", examples=["Steven Spielberg"])
|
||||
review_count: int = Field(..., description="Number of reviewed works by this creator.", examples=[7])
|
||||
average_rating: float = Field(..., description="Mean rating across this creator's reviewed works.", examples=[1.57])
|
||||
|
||||
|
||||
class GenreStats(BaseModel):
|
||||
"""A genre with aggregated review statistics."""
|
||||
|
||||
genre: str = Field(..., description="The genre name.", examples=["Science Fiction"])
|
||||
review_count: int = Field(..., description="Number of reviews in this genre.", examples=[58])
|
||||
average_rating: float = Field(..., description="Mean rating for reviews in this genre.", examples=[1.24])
|
||||
|
||||
|
||||
class YearActivity(BaseModel):
|
||||
"""Review activity for a single year."""
|
||||
|
||||
year: str = Field(..., description="The four-digit year.", examples=["2025"])
|
||||
review_count: int = Field(..., description="Number of reviews recorded in this year.", examples=[3])
|
||||
average_rating: float = Field(..., description="Mean rating for reviews in this year.", examples=[1.67])
|
||||
|
||||
|
||||
class DecadeStats(BaseModel):
|
||||
"""Review activity grouped by the decade the work was released."""
|
||||
|
||||
decade: str = Field(
|
||||
...,
|
||||
description="The decade label (e.g. '1990s').",
|
||||
examples=["1990s"],
|
||||
)
|
||||
review_count: int = Field(
|
||||
...,
|
||||
description="Number of reviewed works released in this decade.",
|
||||
examples=[45],
|
||||
)
|
||||
average_rating: float = Field(
|
||||
...,
|
||||
description="Mean rating for works released in this decade.",
|
||||
examples=[1.35],
|
||||
)
|
||||
0
app/routes/__init__.py
Normal file
0
app/routes/__init__.py
Normal file
76
app/routes/discovery.py
Normal file
76
app/routes/discovery.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import CategoryCount, CreatorCount, GenreCount
|
||||
|
||||
router = APIRouter(tags=["Discovery"])
|
||||
|
||||
@router.get(
|
||||
"/categories",
|
||||
response_model=list[CategoryCount],
|
||||
summary="List all categories",
|
||||
description="Returns every media category, along with a count of the number of reviews in that category."
|
||||
)
|
||||
def list_categories(db: sqlite3.Connection = Depends(get_db)):
|
||||
rows = db.execute(
|
||||
"SELECT category, COUNT(*) as count FROM reviews GROUP BY category ORDER BY count DESC"
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
@router.get(
|
||||
"/genres",
|
||||
response_model=list[GenreCount],
|
||||
summary="List all genres",
|
||||
description="Returns every genre with review counts. Optionally filter to genres within a specific category.",
|
||||
)
|
||||
def list_genres(
|
||||
category: str | None = Query(None, description="Only show genres in this category.", examples=["Movies"]),
|
||||
db: sqlite3.Connection = Depends(get_db),
|
||||
):
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if category:
|
||||
conditions.append("category = ?")
|
||||
params.append(category)
|
||||
|
||||
where = ""
|
||||
if conditions:
|
||||
where = "WHERE " + " AND ".join(conditions)
|
||||
|
||||
rows = db.execute(
|
||||
f"SELECT genre, COUNT(*) as count FROM reviews {where} GROUP BY genre ORDER BY count DESC",
|
||||
params,
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/creators",
|
||||
response_model=list[CreatorCount],
|
||||
summary="List all creators",
|
||||
description="Returns every writer, director, or creator; with the number of works by them that has been reviewed. "
|
||||
"Optionally filter by category.",
|
||||
)
|
||||
def list_creators(
|
||||
category: str | None = Query(None, description="Only show creators in this category.", examples=["Books"]),
|
||||
db: sqlite3.Connection = Depends(get_db),
|
||||
):
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if category:
|
||||
conditions.append("category = ?")
|
||||
params.append(category)
|
||||
|
||||
where = ""
|
||||
if conditions:
|
||||
where = "WHERE " + " AND ".join(conditions)
|
||||
|
||||
rows = db.execute(
|
||||
f"SELECT creator, COUNT(*) as count FROM reviews {where} GROUP BY creator ORDER BY count DESC",
|
||||
params,
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
234
app/routes/reviews.py
Normal file
234
app/routes/reviews.py
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Review, ReviewListResponse
|
||||
|
||||
router = APIRouter(prefix="/reviews", tags=["Reviews"])
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=ReviewListResponse,
|
||||
summary="List reviews",
|
||||
description="Retrieve a paginated list of reviews with optional filters. All filters are combined with AND logic, where only reviews matching every specified filter are returned.",
|
||||
)
|
||||
def list_reviews(
|
||||
category: str | None = Query(
|
||||
None, description="Filter by media category (exact match).", examples=["Movies"]
|
||||
),
|
||||
creator: str | None = Query(
|
||||
None, description="Filter by creator name (exact match).", examples=["Christopher Nolan"]
|
||||
),
|
||||
genre: str | None = Query(
|
||||
None, description="Filter by genre (exact match).", examples=["Science Fiction"]
|
||||
),
|
||||
min_rating: float | None = Query(
|
||||
None, description="Minimum rating (inclusive).", ge=-3.0, le=3.0, examples=[2.0]
|
||||
),
|
||||
max_rating: float | None = Query(
|
||||
None, description="Maximum rating (inclusive).", ge=-3.0, le=3.0, examples=[3.0]
|
||||
),
|
||||
date_from: str | None = Query(
|
||||
None, description="Earliest review date (inclusive, YYYY-MM-DD).", examples=["2020-01-01"]
|
||||
),
|
||||
date_to: str | None = Query(
|
||||
None, description="Latest review date (inclusive, YYYY-MM-DD).", examples=["2025-12-31"]
|
||||
),
|
||||
year_from: int | None = Query(
|
||||
None, description="Earliest release year (inclusive).", examples=[1990]
|
||||
),
|
||||
year_to: int | None = Query(
|
||||
None, description="Latest release year (inclusive).", examples=[2000]
|
||||
),
|
||||
limit: int = Query(
|
||||
20, description="Number of results per page.", ge=1, le=100
|
||||
),
|
||||
offset: int = Query(
|
||||
0, description="Number of results to skip.", ge=0
|
||||
),
|
||||
db: sqlite3.Connection = Depends(get_db),
|
||||
):
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if category:
|
||||
conditions.append("category = ?")
|
||||
params.append(category)
|
||||
if creator:
|
||||
conditions.append("creator = ?")
|
||||
params.append(creator)
|
||||
if genre:
|
||||
conditions.append("genre = ?")
|
||||
params.append(genre)
|
||||
if min_rating is not None:
|
||||
conditions.append("rating >= ?")
|
||||
params.append(min_rating)
|
||||
if max_rating is not None:
|
||||
conditions.append("rating <= ?")
|
||||
params.append(max_rating)
|
||||
if date_from:
|
||||
conditions.append("date >= ?")
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
conditions.append("date <= ?")
|
||||
params.append(date_to)
|
||||
if year_from is not None:
|
||||
conditions.append("year >= ?")
|
||||
params.append(year_from)
|
||||
if year_to is not None:
|
||||
conditions.append("year <= ?")
|
||||
params.append(year_to)
|
||||
|
||||
where = ""
|
||||
if conditions:
|
||||
where = "WHERE " + " AND ".join(conditions)
|
||||
|
||||
total = db.execute(
|
||||
f"SELECT COUNT(*) FROM reviews {where}", params
|
||||
).fetchone()[0]
|
||||
|
||||
rows = db.execute(
|
||||
f"SELECT * FROM reviews {where} ORDER BY date DESC LIMIT ? OFFSET ?",
|
||||
params + [limit, offset],
|
||||
).fetchall()
|
||||
|
||||
return ReviewListResponse(
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
results=[dict(row) for row in rows],
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/random",
|
||||
response_model=Review,
|
||||
summary="Get a random review",
|
||||
description="Returns a single randomly selected review. Optionally filter by category to get a random review from a specific media type.",
|
||||
)
|
||||
def random_review(
|
||||
category: str | None = Query(
|
||||
None, description="Limit random selection to this category.", examples=["Books"]
|
||||
),
|
||||
db: sqlite3.Connection = Depends(get_db),
|
||||
):
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if category:
|
||||
conditions.append("category = ?")
|
||||
params.append(category)
|
||||
|
||||
where = ""
|
||||
if conditions:
|
||||
where = "WHERE " + " AND ".join(conditions)
|
||||
|
||||
row = db.execute(
|
||||
f"SELECT * FROM reviews {where} ORDER BY RANDOM() LIMIT 1", params
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="No reviews found.")
|
||||
|
||||
return dict(row)
|
||||
|
||||
@router.get(
|
||||
"/top",
|
||||
response_model=list[Review],
|
||||
summary="Get top-rated reviews",
|
||||
description="Returns the highest-rated reviews, sorted by rating descending.",
|
||||
)
|
||||
def top_reviews(
|
||||
category: str | None = Query(None, description="Limit to this category.", examples=["Movies"]),
|
||||
year_from: int | None = Query(None, description="Earliest release year (inclusive).", examples=[1990]),
|
||||
year_to: int | None = Query(None, description="Latest release year (inclusive).", examples=[2000]),
|
||||
limit: int = Query(10, description="Number of results to return.", ge=1, le=50),
|
||||
db: sqlite3.Connection = Depends(get_db),
|
||||
):
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if category:
|
||||
conditions.append("category = ?")
|
||||
params.append(category)
|
||||
if year_from is not None:
|
||||
conditions.append("year >= ?")
|
||||
params.append(year_from)
|
||||
if year_to is not None:
|
||||
conditions.append("year <= ?")
|
||||
params.append(year_to)
|
||||
|
||||
where = ""
|
||||
if conditions:
|
||||
where = "WHERE " + " AND ".join(conditions)
|
||||
|
||||
rows = db.execute(
|
||||
f"SELECT * FROM reviews {where} ORDER BY rating DESC LIMIT ?",
|
||||
params + [limit],
|
||||
).fetchall()
|
||||
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
@router.get(
|
||||
"/bottom",
|
||||
response_model=list[Review],
|
||||
summary="Get lowest-rated reviews",
|
||||
description="Returns the lowest-rated reviews, sorted by rating ascending. "
|
||||
"These are the harshest takes in the collection.",
|
||||
)
|
||||
def bottom_reviews(
|
||||
category: str | None = Query(None, description="Limit to this category.", examples=["Movies"]),
|
||||
year_from: int | None = Query(None, description="Earliest release year (inclusive).", examples=[1990]),
|
||||
year_to: int | None = Query(None, description="Latest release year (inclusive).", examples=[2000]),
|
||||
limit: int = Query(10, description="Number of results to return.", ge=1, le=50),
|
||||
db: sqlite3.Connection = Depends(get_db),
|
||||
):
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if category:
|
||||
conditions.append("category = ?")
|
||||
params.append(category)
|
||||
if year_from is not None:
|
||||
conditions.append("year >= ?")
|
||||
params.append(year_from)
|
||||
if year_to is not None:
|
||||
conditions.append("year <= ?")
|
||||
params.append(year_to)
|
||||
|
||||
where = ""
|
||||
if conditions:
|
||||
where = "WHERE " + " AND ".join(conditions)
|
||||
|
||||
rows = db.execute(
|
||||
f"SELECT * FROM reviews {where} ORDER BY rating ASC LIMIT ?",
|
||||
params + [limit],
|
||||
).fetchall()
|
||||
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
@router.get(
|
||||
"/{category}/{title}/{creator}",
|
||||
response_model=Review,
|
||||
summary="Get a specific review",
|
||||
description="Retrieve a single review by its unique combination of "
|
||||
"category, title, and creator.",
|
||||
)
|
||||
def get_review(
|
||||
category: str,
|
||||
title: str,
|
||||
creator: str,
|
||||
db: sqlite3.Connection = Depends(get_db),
|
||||
):
|
||||
row = db.execute(
|
||||
"SELECT * FROM reviews WHERE category = ? AND title = ? AND creator = ?",
|
||||
[category, title, creator],
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No review found for '{title}' by {creator} in {category}.",
|
||||
)
|
||||
|
||||
return dict(row)
|
||||
152
app/routes/stats.py
Normal file
152
app/routes/stats.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import CreatorStats, DecadeStats, GenreStats, OverviewStats, YearActivity
|
||||
|
||||
router = APIRouter(prefix="/stats", tags=["Statistics"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=OverviewStats,
|
||||
summary="Overall statistics",
|
||||
description="High-level numbers: total reviews, breakdown by category, average rating, and date range of the reviews collection.",
|
||||
)
|
||||
def overview(db: sqlite3.Connection = Depends(get_db)):
|
||||
total = db.execute("SELECT COUNT(*) FROM reviews").fetchone()[0]
|
||||
|
||||
categories = db.execute(
|
||||
"SELECT category, COUNT(*) as count FROM reviews GROUP BY category ORDER BY count DESC"
|
||||
).fetchall()
|
||||
|
||||
avg = db.execute("SELECT ROUND(AVG(rating), 2) FROM reviews").fetchone()[0]
|
||||
earliest = db.execute("SELECT MIN(date) FROM reviews").fetchone()[0]
|
||||
latest = db.execute("SELECT MAX(date) FROM reviews").fetchone()[0]
|
||||
|
||||
return OverviewStats(
|
||||
total_reviews=total,
|
||||
categories=[dict(row) for row in categories],
|
||||
average_rating=avg,
|
||||
earliest_review=earliest,
|
||||
latest_review=latest,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/creators",
|
||||
response_model=list[CreatorStats],
|
||||
summary="Creator rankings",
|
||||
description="Writers, directors and creators ranked by number of reviewed works, with their average rating.",
|
||||
)
|
||||
def creator_stats(
|
||||
category: str | None = Query(None, description="Limit to this category.", examples=["Movies"]),
|
||||
limit: int = Query(20, description="Number of creators to return.", ge=1, le=100),
|
||||
db: sqlite3.Connection = Depends(get_db),
|
||||
):
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if category:
|
||||
conditions.append("category = ?")
|
||||
params.append(category)
|
||||
|
||||
where = ""
|
||||
if conditions:
|
||||
where = "WHERE " + " AND ".join(conditions)
|
||||
|
||||
rows = db.execute(
|
||||
f"SELECT creator, COUNT(*) as review_count, "
|
||||
f"ROUND(AVG(rating), 2) as average_rating "
|
||||
f"FROM reviews {where} "
|
||||
f"GROUP BY creator ORDER BY review_count DESC LIMIT ?",
|
||||
params + [limit],
|
||||
).fetchall()
|
||||
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/genres",
|
||||
response_model=list[GenreStats],
|
||||
summary="Genre breakdown",
|
||||
description="Genres with review counts and average ratings. Optionally filtered by media category.",
|
||||
)
|
||||
def genre_stats(
|
||||
category: str | None = Query(None, description="Limit to this category.", examples=["Movies"]),
|
||||
db: sqlite3.Connection = Depends(get_db),
|
||||
):
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if category:
|
||||
conditions.append("category = ?")
|
||||
params.append(category)
|
||||
|
||||
where = ""
|
||||
if conditions:
|
||||
where = "WHERE " + " AND ".join(conditions)
|
||||
|
||||
rows = db.execute(
|
||||
f"SELECT genre, COUNT(*) as review_count, "
|
||||
f"ROUND(AVG(rating), 2) as average_rating "
|
||||
f"FROM reviews {where} "
|
||||
f"GROUP BY genre ORDER BY review_count DESC",
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/timeline",
|
||||
response_model=list[YearActivity],
|
||||
summary="Review timeline",
|
||||
description="Reviews per year, showing how my reviewing activity changed over time.",
|
||||
)
|
||||
def timeline(db: sqlite3.Connection = Depends(get_db)):
|
||||
rows = db.execute(
|
||||
"SELECT SUBSTR(date, 1, 4) as year, COUNT(*) as review_count, "
|
||||
"ROUND(AVG(rating), 2) as average_rating "
|
||||
"FROM reviews GROUP BY year ORDER BY year ASC"
|
||||
).fetchall()
|
||||
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/decades",
|
||||
response_model=list[DecadeStats],
|
||||
summary="Reviews by decade of release",
|
||||
description="Reviews grouped by the decade the work was released. Shows which eras of media appear most in the reviews collection.",
|
||||
)
|
||||
def decade_stats(
|
||||
category: str | None = Query(None, description="Limit to this category.", examples=["Movies"]),
|
||||
db: sqlite3.Connection = Depends(get_db),
|
||||
):
|
||||
conditions = ["year IS NOT NULL"]
|
||||
params = []
|
||||
|
||||
if category:
|
||||
conditions.append("category = ?")
|
||||
params.append(category)
|
||||
|
||||
where = "WHERE " + " AND ".join(conditions)
|
||||
|
||||
rows = db.execute(
|
||||
f"SELECT (year / 10 * 10) as decade_num, COUNT(*) as review_count, "
|
||||
f"ROUND(AVG(rating), 2) as average_rating "
|
||||
f"FROM reviews {where} "
|
||||
f"GROUP BY decade_num ORDER BY decade_num ASC",
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
return [
|
||||
DecadeStats(
|
||||
decade=f"{row['decade_num']}s",
|
||||
review_count=row["review_count"],
|
||||
average_rating=row["average_rating"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue