feat: master feed card (#15)

* feat: master feed card

* fix: adjust tests
This commit is contained in:
Leon
2025-09-17 18:19:59 +01:00
committed by GitHub
parent 22e8eeac3d
commit 7de80c6d57
9 changed files with 250 additions and 5 deletions

View File

@@ -1,5 +1,5 @@
from nanoid import generate
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, joinedload
from app.core.logging import get_logger
from app.models.entries import Entry
@@ -8,6 +8,19 @@ from app.schemas.entries import EntryCreate
logger = get_logger(__name__)
def get_all_entries(db: Session, skip: int = 0, limit: int = 100):
"""Retrieve all entries from all newsletters, sorted by received date."""
logger.debug(f"Querying all entries with skip={skip}, limit={limit}")
return (
db.query(Entry)
.options(joinedload(Entry.newsletter))
.order_by(Entry.received_at.desc())
.offset(skip)
.limit(limit)
.all()
)
def get_entries_by_newsletter(
db: Session, newsletter_id: str, skip: int = 0, limit: int = 100
):

View File

@@ -4,12 +4,21 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.logging import get_logger
from app.services.feed_generator import generate_feed
from app.services.feed_generator import generate_feed, generate_master_feed
logger = get_logger(__name__)
router = APIRouter()
@router.get("/feeds/all")
def get_master_feed(db: Session = Depends(get_db)):
"""Generate a master Atom feed for all newsletters."""
logger.info("Generating master feed for all newsletters")
feed = generate_master_feed(db)
logger.info("Successfully generated master feed")
return Response(content=feed, media_type="application/atom+xml")
@router.get("/feeds/{feed_identifier}")
def get_newsletter_feed(feed_identifier: str, db: Session = Depends(get_db)):
"""Generate an Atom feed for a specific newsletter."""

View File

@@ -3,7 +3,7 @@ from feedgen.feed import FeedGenerator
from sqlalchemy.orm import Session
from app.core.config import settings
from app.crud.entries import get_entries_by_newsletter
from app.crud.entries import get_all_entries, get_entries_by_newsletter
from app.crud.newsletters import get_newsletter_by_identifier
@@ -41,3 +41,34 @@ def generate_feed(db: Session, feed_identifier: str):
fe.published(entry.received_at)
return fg.atom_str(pretty=True)
def generate_master_feed(db: Session):
"""Generate a master Atom feed for all newsletters."""
entries = get_all_entries(db)
feed_url = f"{settings.app_base_url}/feeds/all"
logo_url = f"{settings.app_base_url}/logo.png"
icon_url = f"{settings.app_base_url}/favicon.ico"
fg = FeedGenerator()
fg.id("urn:letterfeed:master")
fg.title("LetterFeed: All Newsletters")
fg.logo(logo_url)
fg.icon(icon_url)
fg.link(href=feed_url, rel="self")
fg.link(href=f"{settings.app_base_url}/", rel="alternate")
fg.description("A master feed of all your newsletters.")
for entry in entries:
fe = fg.add_entry()
fe.id(f"urn:letterfeed:entry:{entry.id}")
fe.title(f"[{entry.newsletter.name}] {entry.subject}")
fe.content(entry.body, type="html")
if entry.received_at.tzinfo is None:
timezone_aware_received_at = entry.received_at.replace(tzinfo=tz.tzutc())
fe.published(timezone_aware_received_at)
else:
fe.published(entry.received_at)
return fg.atom_str(pretty=True)

View File

@@ -1,9 +1,10 @@
import time
import uuid
from unittest.mock import patch
from sqlalchemy.orm import Session
from app.crud.entries import create_entry, get_entries_by_newsletter
from app.crud.entries import create_entry, get_all_entries, get_entries_by_newsletter
from app.crud.newsletters import (
create_newsletter,
get_newsletter_by_identifier,
@@ -329,3 +330,59 @@ def test_create_multiple_entries_have_different_timestamps(db_session: Session):
entry2 = create_entry(db_session, entry_data_2, newsletter.id)
assert entry1.received_at != entry2.received_at
def test_get_all_entries(db_session: Session):
"""Test getting all entries from all newsletters."""
# Create two newsletters
newsletter1 = create_newsletter(
db_session,
NewsletterCreate(
name="Newsletter One", sender_emails=[f"one_{uuid.uuid4()}@test.com"]
),
)
time.sleep(0.1) # Ensure different timestamps
newsletter2 = create_newsletter(
db_session,
NewsletterCreate(
name="Newsletter Two", sender_emails=[f"two_{uuid.uuid4()}@test.com"]
),
)
# Create entries for both
entry1 = create_entry(
db_session,
EntryCreate(
subject="Entry 1", body="Body 1", message_id=f"<{uuid.uuid4()}@test.com>"
),
newsletter1.id,
)
time.sleep(0.1)
entry2 = create_entry(
db_session,
EntryCreate(
subject="Entry 2", body="Body 2", message_id=f"<{uuid.uuid4()}@test.com>"
),
newsletter2.id,
)
time.sleep(0.1)
entry3 = create_entry(
db_session,
EntryCreate(
subject="Entry 3", body="Body 3", message_id=f"<{uuid.uuid4()}@test.com>"
),
newsletter1.id,
)
# Get all entries
all_entries = get_all_entries(db_session, limit=10)
# Assertions
assert len(all_entries) == 3
# Check for descending order by received_at
assert all_entries[0].id == entry3.id
assert all_entries[1].id == entry2.id
assert all_entries[2].id == entry1.id
# Check that newsletter relationship is loaded
assert all_entries[0].newsletter.name == "Newsletter One"
assert all_entries[1].newsletter.name == "Newsletter Two"

View File

@@ -7,7 +7,51 @@ from app.crud.entries import create_entry
from app.crud.newsletters import create_newsletter
from app.schemas.entries import EntryCreate
from app.schemas.newsletters import NewsletterCreate
from app.services.feed_generator import generate_feed
from app.services.feed_generator import generate_feed, generate_master_feed
def test_generate_master_feed(db_session: Session):
"""Test the master feed generation for all newsletters."""
# Create newsletters and entries
nl1 = create_newsletter(
db_session,
NewsletterCreate(name="Newsletter A", sender_emails=["a@example.com"]),
)
create_entry(
db_session,
EntryCreate(
subject="Entry A1", body="<p>Body A1</p>", message_id=f"<{uuid.uuid4()}>"
),
nl1.id,
)
nl2 = create_newsletter(
db_session,
NewsletterCreate(name="Newsletter B", sender_emails=["b@example.com"]),
)
create_entry(
db_session,
EntryCreate(
subject="Entry B1", body="<p>Body B1</p>", message_id=f"<{uuid.uuid4()}>"
),
nl2.id,
)
# Generate the master feed
feed_xml = generate_master_feed(db_session)
assert feed_xml is not None
# Parse and verify
root = ET.fromstring(feed_xml)
ns = {"atom": "http://www.w3.org/2005/Atom"}
assert root.find("atom:title", ns).text == "LetterFeed: All Newsletters"
assert root.find("atom:id", ns).text == "urn:letterfeed:master"
entry_titles = {
entry.find("atom:title", ns).text for entry in root.findall("atom:entry", ns)
}
assert "[Newsletter A] Entry A1" in entry_titles
assert "[Newsletter B] Entry B1" in entry_titles
def test_generate_feed(db_session: Session):