mirror of
https://github.com/kennethreitz/kjvstudy.org.git
synced 2026-06-05 23:00:16 +00:00
Add red letter (words of Christ) API endpoints
- Added /api/red-letter endpoint for listing all verses where Jesus speaks - Supports filtering by book - Includes pagination (limit/offset) - Returns verse text, reference, and whether full or partial - Added /api/red-letter/stats endpoint for statistics - Total count of red letter verses - Breakdown by full vs partial verses - Count by book - List of books containing red letter verses - Added 8 comprehensive tests (59 total tests now) - All tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+140
-3
@@ -16,7 +16,7 @@ from ..utils.books import normalize_book_name, OT_BOOKS
|
||||
from ..utils.search import perform_full_text_search
|
||||
from ..utils.helpers import get_daily_verse, create_slug
|
||||
from ..books import get_book_data, get_all_books_metadata, has_book_data
|
||||
from ..red_letter import get_christ_words
|
||||
from ..red_letter import get_christ_words, load_red_letter_verses
|
||||
from ..stories import (
|
||||
get_categories,
|
||||
get_story_by_slug,
|
||||
@@ -147,6 +147,34 @@ class ResourceItemDetail(BaseModel):
|
||||
verses: List[ResourceVerse]
|
||||
|
||||
|
||||
class RedLetterVerse(BaseModel):
|
||||
"""A verse containing words of Christ"""
|
||||
reference: str = Field(..., json_schema_extra={"example": "John 3:16"})
|
||||
book: str = Field(..., json_schema_extra={"example": "John"})
|
||||
chapter: int = Field(..., json_schema_extra={"example": 3})
|
||||
verse: int = Field(..., json_schema_extra={"example": 16})
|
||||
text: str = Field(..., json_schema_extra={"example": "For God so loved the world..."})
|
||||
christ_words: str = Field(..., json_schema_extra={"example": "full"})
|
||||
is_full_verse: bool = Field(..., json_schema_extra={"example": True})
|
||||
|
||||
|
||||
class RedLetterListResponse(BaseModel):
|
||||
"""Response listing red letter verses"""
|
||||
total: int = Field(..., json_schema_extra={"example": 1842})
|
||||
verses: List[RedLetterVerse]
|
||||
limit: int = Field(..., json_schema_extra={"example": 50})
|
||||
offset: int = Field(..., json_schema_extra={"example": 0})
|
||||
|
||||
|
||||
class RedLetterStatsResponse(BaseModel):
|
||||
"""Statistics about red letter verses"""
|
||||
total_verses: int = Field(..., json_schema_extra={"example": 1842})
|
||||
full_verses: int = Field(..., json_schema_extra={"example": 1456})
|
||||
partial_verses: int = Field(..., json_schema_extra={"example": 386})
|
||||
books_with_red_letter: List[str] = Field(..., json_schema_extra={"example": ["Matthew", "Mark", "Luke", "John"]})
|
||||
by_book: dict = Field(..., json_schema_extra={"example": {"Matthew": 644, "Mark": 285}})
|
||||
|
||||
|
||||
# Mapping of category names to their data dictionaries
|
||||
CATEGORY_TO_DATA = {
|
||||
'biblical_locations': BIBLICAL_LOCATIONS,
|
||||
@@ -227,7 +255,9 @@ def api_index():
|
||||
"reading_plans": "/api/reading-plans",
|
||||
"reading_plan": "/api/reading-plans/{plan_id}",
|
||||
"stories": "/api/stories",
|
||||
"story": "/api/stories/{slug}"
|
||||
"story": "/api/stories/{slug}",
|
||||
"red_letter": "/api/red-letter?book={book}&limit={limit}&offset={offset}",
|
||||
"red_letter_stats": "/api/red-letter/stats"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1532,7 +1562,7 @@ async def api_get_resource_item_pdf(
|
||||
|
||||
# Generate PDF
|
||||
pdf_buffer = await render_html_to_pdf_async(html_content)
|
||||
|
||||
|
||||
# Return as downloadable PDF
|
||||
filename = f"{slug}-{category}.pdf"
|
||||
return StreamingResponse(
|
||||
@@ -1540,3 +1570,110 @@ async def api_get_resource_item_pdf(
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/red-letter",
|
||||
response_model=RedLetterListResponse,
|
||||
summary="List all red letter verses",
|
||||
description="Get a list of all verses containing the words of Jesus Christ (red letter edition). Supports filtering by book and pagination."
|
||||
)
|
||||
def api_list_red_letter_verses(
|
||||
book: Optional[str] = Query(None, description="Filter by book name", example="John"),
|
||||
limit: int = Query(50, description="Maximum number of verses to return", example=50, ge=1, le=500),
|
||||
offset: int = Query(0, description="Number of verses to skip", example=0, ge=0)
|
||||
):
|
||||
"""List all red letter verses with optional filtering and pagination."""
|
||||
red_letter_data = load_red_letter_verses()
|
||||
|
||||
# Parse all verses and build result list
|
||||
all_verses = []
|
||||
for verse_ref, christ_words in red_letter_data.items():
|
||||
# Parse the reference (format: "Book Chapter:Verse")
|
||||
parts = verse_ref.rsplit(' ', 1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
|
||||
book_name = parts[0]
|
||||
chapter_verse = parts[1].split(':')
|
||||
if len(chapter_verse) != 2:
|
||||
continue
|
||||
|
||||
try:
|
||||
chapter_num = int(chapter_verse[0])
|
||||
verse_num = int(chapter_verse[1])
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Apply book filter if specified
|
||||
if book and book_name != book:
|
||||
# Try normalizing the book name in case it's an abbreviation
|
||||
canonical = normalize_book_name(book)
|
||||
if not canonical or book_name != canonical:
|
||||
continue
|
||||
|
||||
# Get the verse text
|
||||
verse_text = bible.get_verse_text(book_name, chapter_num, verse_num)
|
||||
if not verse_text:
|
||||
continue
|
||||
|
||||
all_verses.append({
|
||||
"reference": verse_ref,
|
||||
"book": book_name,
|
||||
"chapter": chapter_num,
|
||||
"verse": verse_num,
|
||||
"text": verse_text,
|
||||
"christ_words": christ_words,
|
||||
"is_full_verse": christ_words == "full"
|
||||
})
|
||||
|
||||
# Apply pagination
|
||||
total = len(all_verses)
|
||||
paginated_verses = all_verses[offset:offset + limit]
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"verses": paginated_verses,
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/red-letter/stats",
|
||||
response_model=RedLetterStatsResponse,
|
||||
summary="Get red letter statistics",
|
||||
description="Get statistics about verses containing the words of Jesus Christ, including counts by book and full vs. partial verses."
|
||||
)
|
||||
def api_red_letter_stats():
|
||||
"""Get statistics about red letter verses in the Bible."""
|
||||
red_letter_data = load_red_letter_verses()
|
||||
|
||||
total_verses = len(red_letter_data)
|
||||
full_verses = sum(1 for v in red_letter_data.values() if v == "full")
|
||||
partial_verses = total_verses - full_verses
|
||||
|
||||
# Count by book
|
||||
by_book = {}
|
||||
books_set = set()
|
||||
|
||||
for verse_ref in red_letter_data.keys():
|
||||
# Parse the reference (format: "Book Chapter:Verse")
|
||||
parts = verse_ref.rsplit(' ', 1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
|
||||
book_name = parts[0]
|
||||
books_set.add(book_name)
|
||||
by_book[book_name] = by_book.get(book_name, 0) + 1
|
||||
|
||||
# Sort books by count (descending)
|
||||
by_book = dict(sorted(by_book.items(), key=lambda x: x[1], reverse=True))
|
||||
|
||||
return {
|
||||
"total_verses": total_verses,
|
||||
"full_verses": full_verses,
|
||||
"partial_verses": partial_verses,
|
||||
"books_with_red_letter": sorted(list(books_set)),
|
||||
"by_book": by_book
|
||||
}
|
||||
|
||||
+154
-2
@@ -546,11 +546,163 @@ class TestResourcesEndpoints:
|
||||
response = client.get("/api/resources")
|
||||
assert response.status_code == 200
|
||||
categories = response.json()["categories"]
|
||||
|
||||
|
||||
# Test a sample of categories (not all 39 to keep test fast)
|
||||
sample_categories = [cat["name"] for cat in categories[:10]]
|
||||
|
||||
|
||||
for cat_name in sample_categories:
|
||||
response = client.get(f"/api/resources/{cat_name}")
|
||||
assert response.status_code == 200, f"Failed to access category: {cat_name}"
|
||||
assert response.json()["category"] == cat_name
|
||||
|
||||
|
||||
class TestRedLetterEndpoints:
|
||||
"""Tests for red letter (words of Christ) endpoints"""
|
||||
|
||||
def test_list_red_letter_verses(self, client):
|
||||
"""Test /api/red-letter endpoint returns list of verses"""
|
||||
response = client.get("/api/red-letter")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check response structure
|
||||
assert "total" in data
|
||||
assert "verses" in data
|
||||
assert "limit" in data
|
||||
assert "offset" in data
|
||||
|
||||
# Verify we have verses
|
||||
assert data["total"] > 0
|
||||
assert len(data["verses"]) > 0
|
||||
|
||||
# Check first verse structure
|
||||
first_verse = data["verses"][0]
|
||||
assert "reference" in first_verse
|
||||
assert "book" in first_verse
|
||||
assert "chapter" in first_verse
|
||||
assert "verse" in first_verse
|
||||
assert "text" in first_verse
|
||||
assert "christ_words" in first_verse
|
||||
assert "is_full_verse" in first_verse
|
||||
|
||||
def test_list_red_letter_with_pagination(self, client):
|
||||
"""Test pagination parameters work correctly"""
|
||||
# Get first 10 verses
|
||||
response1 = client.get("/api/red-letter?limit=10&offset=0")
|
||||
assert response1.status_code == 200
|
||||
data1 = response1.json()
|
||||
assert data1["limit"] == 10
|
||||
assert data1["offset"] == 0
|
||||
assert len(data1["verses"]) == 10
|
||||
|
||||
# Get next 10 verses
|
||||
response2 = client.get("/api/red-letter?limit=10&offset=10")
|
||||
assert response2.status_code == 200
|
||||
data2 = response2.json()
|
||||
assert data2["limit"] == 10
|
||||
assert data2["offset"] == 10
|
||||
assert len(data2["verses"]) == 10
|
||||
|
||||
# Verify different verses
|
||||
assert data1["verses"][0]["reference"] != data2["verses"][0]["reference"]
|
||||
|
||||
def test_list_red_letter_filter_by_book(self, client):
|
||||
"""Test filtering by book works correctly"""
|
||||
response = client.get("/api/red-letter?book=John")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# All verses should be from John
|
||||
assert data["total"] > 0
|
||||
for verse in data["verses"]:
|
||||
assert verse["book"] == "John"
|
||||
|
||||
def test_list_red_letter_filter_by_book_multiple_books(self, client):
|
||||
"""Test that different books return different results"""
|
||||
response_john = client.get("/api/red-letter?book=John&limit=10")
|
||||
response_matthew = client.get("/api/red-letter?book=Matthew&limit=10")
|
||||
|
||||
assert response_john.status_code == 200
|
||||
assert response_matthew.status_code == 200
|
||||
|
||||
john_data = response_john.json()
|
||||
matthew_data = response_matthew.json()
|
||||
|
||||
# Both should have verses
|
||||
assert john_data["total"] > 0
|
||||
assert matthew_data["total"] > 0
|
||||
|
||||
# All verses in each response should be from the correct book
|
||||
for verse in john_data["verses"]:
|
||||
assert verse["book"] == "John"
|
||||
for verse in matthew_data["verses"]:
|
||||
assert verse["book"] == "Matthew"
|
||||
|
||||
def test_red_letter_stats(self, client):
|
||||
"""Test /api/red-letter/stats endpoint"""
|
||||
response = client.get("/api/red-letter/stats")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check response structure
|
||||
assert "total_verses" in data
|
||||
assert "full_verses" in data
|
||||
assert "partial_verses" in data
|
||||
assert "books_with_red_letter" in data
|
||||
assert "by_book" in data
|
||||
|
||||
# Verify counts make sense
|
||||
assert data["total_verses"] > 0
|
||||
assert data["full_verses"] > 0
|
||||
assert data["partial_verses"] >= 0
|
||||
assert data["total_verses"] == data["full_verses"] + data["partial_verses"]
|
||||
|
||||
# Verify books list
|
||||
assert len(data["books_with_red_letter"]) > 0
|
||||
assert "Matthew" in data["books_with_red_letter"]
|
||||
assert "Mark" in data["books_with_red_letter"]
|
||||
assert "Luke" in data["books_with_red_letter"]
|
||||
assert "John" in data["books_with_red_letter"]
|
||||
|
||||
# Verify by_book counts
|
||||
assert isinstance(data["by_book"], dict)
|
||||
assert "Matthew" in data["by_book"]
|
||||
assert data["by_book"]["Matthew"] > 0
|
||||
|
||||
def test_red_letter_verses_contain_expected_data(self, client):
|
||||
"""Test that specific known red letter verses are included"""
|
||||
response = client.get("/api/red-letter?book=John&limit=500")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Find John 3:16 (Jesus speaks the entire verse)
|
||||
john_316 = None
|
||||
for verse in data["verses"]:
|
||||
if verse["chapter"] == 3 and verse["verse"] == 16:
|
||||
john_316 = verse
|
||||
break
|
||||
|
||||
assert john_316 is not None, "John 3:16 should be in red letter verses"
|
||||
assert john_316["is_full_verse"] is True
|
||||
assert john_316["christ_words"] == "full"
|
||||
assert "God so loved the world" in john_316["text"]
|
||||
|
||||
def test_red_letter_pagination_limits(self, client):
|
||||
"""Test pagination limit validation"""
|
||||
# Test maximum limit
|
||||
response = client.get("/api/red-letter?limit=500")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Test exceeding maximum limit should be rejected
|
||||
response = client.get("/api/red-letter?limit=1000")
|
||||
assert response.status_code == 422 # Validation error
|
||||
|
||||
def test_api_index_includes_red_letter_endpoints(self, client):
|
||||
"""Test that API index includes red letter endpoints"""
|
||||
response = client.get("/api/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert "endpoints" in data
|
||||
assert "red_letter" in data["endpoints"]
|
||||
assert "red_letter_stats" in data["endpoints"]
|
||||
|
||||
Reference in New Issue
Block a user