mirror of
https://github.com/khoaliber/LetterFeed.git
synced 2026-03-02 21:19:13 +00:00
feat: master feed card (#15)
* feat: master feed card * fix: adjust tests
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
from nanoid import generate
|
from nanoid import generate
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.models.entries import Entry
|
from app.models.entries import Entry
|
||||||
@@ -8,6 +8,19 @@ from app.schemas.entries import EntryCreate
|
|||||||
logger = get_logger(__name__)
|
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(
|
def get_entries_by_newsletter(
|
||||||
db: Session, newsletter_id: str, skip: int = 0, limit: int = 100
|
db: Session, newsletter_id: str, skip: int = 0, limit: int = 100
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -4,12 +4,21 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.logging import get_logger
|
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__)
|
logger = get_logger(__name__)
|
||||||
router = APIRouter()
|
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}")
|
@router.get("/feeds/{feed_identifier}")
|
||||||
def get_newsletter_feed(feed_identifier: str, db: Session = Depends(get_db)):
|
def get_newsletter_feed(feed_identifier: str, db: Session = Depends(get_db)):
|
||||||
"""Generate an Atom feed for a specific newsletter."""
|
"""Generate an Atom feed for a specific newsletter."""
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from feedgen.feed import FeedGenerator
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
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
|
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)
|
fe.published(entry.received_at)
|
||||||
|
|
||||||
return fg.atom_str(pretty=True)
|
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)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
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 (
|
from app.crud.newsletters import (
|
||||||
create_newsletter,
|
create_newsletter,
|
||||||
get_newsletter_by_identifier,
|
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)
|
entry2 = create_entry(db_session, entry_data_2, newsletter.id)
|
||||||
|
|
||||||
assert entry1.received_at != entry2.received_at
|
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"
|
||||||
|
|||||||
@@ -7,7 +7,51 @@ from app.crud.entries import create_entry
|
|||||||
from app.crud.newsletters import create_newsletter
|
from app.crud.newsletters import create_newsletter
|
||||||
from app.schemas.entries import EntryCreate
|
from app.schemas.entries import EntryCreate
|
||||||
from app.schemas.newsletters import NewsletterCreate
|
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):
|
def test_generate_feed(db_session: Session):
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { LoadingSpinner } from "@/components/letterfeed/LoadingSpinner"
|
|||||||
import { Header } from "@/components/letterfeed/Header"
|
import { Header } from "@/components/letterfeed/Header"
|
||||||
import { NewsletterList } from "@/components/letterfeed/NewsletterList"
|
import { NewsletterList } from "@/components/letterfeed/NewsletterList"
|
||||||
import { EmptyState } from "@/components/letterfeed/EmptyState"
|
import { EmptyState } from "@/components/letterfeed/EmptyState"
|
||||||
|
import { MasterFeedCard } from "@/components/letterfeed/MasterFeedCard"
|
||||||
import { NewsletterDialog } from "@/components/letterfeed/NewsletterDialog"
|
import { NewsletterDialog } from "@/components/letterfeed/NewsletterDialog"
|
||||||
import { SettingsDialog } from "@/components/letterfeed/SettingsDialog"
|
import { SettingsDialog } from "@/components/letterfeed/SettingsDialog"
|
||||||
|
|
||||||
@@ -68,6 +69,8 @@ function LetterFeedApp() {
|
|||||||
onOpenSettings={() => setIsSettingsOpen(true)}
|
onOpenSettings={() => setIsSettingsOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{newsletters.length > 0 && <MasterFeedCard />}
|
||||||
|
|
||||||
{newsletters.length > 0 ? (
|
{newsletters.length > 0 ? (
|
||||||
<NewsletterList newsletters={newsletters} onEditNewsletter={openEditDialog} />
|
<NewsletterList newsletters={newsletters} onEditNewsletter={openEditDialog} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
39
frontend/src/components/letterfeed/MasterFeedCard.tsx
Normal file
39
frontend/src/components/letterfeed/MasterFeedCard.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Rss, ExternalLink } from "lucide-react"
|
||||||
|
import { getMasterFeedUrl } from "@/lib/api"
|
||||||
|
|
||||||
|
export function MasterFeedCard() {
|
||||||
|
const feedUrl = getMasterFeedUrl()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mb-8">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Rss className="w-5 h-5 text-orange-500" />
|
||||||
|
Master Feed
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This feed contains all entries from all your newsletters in one place.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">RSS Feed URL</h4>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href={feedUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
{feedUrl}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { render, screen } from "@testing-library/react"
|
||||||
|
import "@testing-library/jest-dom"
|
||||||
|
import { MasterFeedCard } from "../MasterFeedCard"
|
||||||
|
|
||||||
|
// Mock the getMasterFeedUrl function
|
||||||
|
jest.mock("@/lib/api", () => ({
|
||||||
|
...jest.requireActual("@/lib/api"),
|
||||||
|
getMasterFeedUrl: jest.fn(() => "http://mock-api/feeds/all"),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock the toast
|
||||||
|
jest.mock("sonner", () => ({
|
||||||
|
toast: {
|
||||||
|
success: jest.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock navigator.clipboard
|
||||||
|
Object.assign(navigator, {
|
||||||
|
clipboard: {
|
||||||
|
writeText: jest.fn(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("MasterFeedCard", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders the master feed card with the correct URL", () => {
|
||||||
|
render(<MasterFeedCard />)
|
||||||
|
|
||||||
|
expect(screen.getByText("Master Feed")).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"This feed contains all entries from all your newsletters in one place."
|
||||||
|
)
|
||||||
|
).toBeInTheDocument()
|
||||||
|
|
||||||
|
const feedLink = screen.getByRole("link")
|
||||||
|
expect(feedLink).toHaveAttribute("href", "http://mock-api/feeds/all")
|
||||||
|
expect(feedLink).toHaveTextContent("http://mock-api/feeds/all")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -208,3 +208,7 @@ export function getFeedUrl(newsletter: Newsletter): string {
|
|||||||
const feedIdentifier = newsletter.slug || newsletter.id;
|
const feedIdentifier = newsletter.slug || newsletter.id;
|
||||||
return `${API_BASE_URL}/feeds/${feedIdentifier}`;
|
return `${API_BASE_URL}/feeds/${feedIdentifier}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMasterFeedUrl(): string {
|
||||||
|
return `${API_BASE_URL}/feeds/all`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user