Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 53 additions & 22 deletions src/apps/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.core.cache import cache
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
from django.db import models
from django.http import HttpRequest, HttpResponse, JsonResponse
Expand Down Expand Up @@ -543,40 +544,70 @@ class BlogFeedView(View):

FEED_URL = "https://acoruss.substack.com/feed"
CACHE_TIMEOUT = 60 * 15 # 15 minutes
# Use a browser-like User-Agent to avoid 403 from Substack
USER_AGENT = "Mozilla/5.0 (compatible; AcorussFeedBot/1.0; +https://acoruss.com)"
STALE_CACHE_TIMEOUT = 60 * 60 * 24 # 24 hours
FEED_CACHE_KEY = "core:blog_feed:posts"
FEED_STALE_CACHE_KEY = "core:blog_feed:posts:stale"
# Use a normal browser-like User-Agent to reduce bot blocking.
USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
)

def _parse_posts(self, body: bytes) -> list[dict[str, str]]:
root = ET.fromstring(body) # noqa: S314

posts = []
for item in root.findall(".//item")[:6]:
title = item.findtext("title", "")
link = item.findtext("link", "")
pub_date = item.findtext("pubDate", "")
description = item.findtext("description", "")
# Strip HTML tags and decode entities for summary.
summary = unescape(re_sub(r"<[^>]+>", "", description))[:200]

posts.append(
{
"title": title,
"link": link,
"published": pub_date,
"summary": summary,
}
)

return posts

async def get(self, request: HttpRequest) -> JsonResponse:
import asyncio
from urllib.request import Request, urlopen

fresh_cached_posts = cache.get(self.FEED_CACHE_KEY)
if fresh_cached_posts:
return JsonResponse(fresh_cached_posts, safe=False)

try:
loop = asyncio.get_event_loop()
req = Request(self.FEED_URL, headers={"User-Agent": self.USER_AGENT}) # noqa: S310
req = Request( # noqa: S310
self.FEED_URL,
headers={
"User-Agent": self.USER_AGENT,
"Accept": "application/rss+xml, application/xml;q=0.9, */*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Referer": "https://acoruss.com/",
},
)
body = await loop.run_in_executor(None, lambda: urlopen(req, timeout=10).read()) # noqa: S310
root = ET.fromstring(body) # noqa: S314

posts = []
for item in root.findall(".//item")[:6]:
title = item.findtext("title", "")
link = item.findtext("link", "")
pub_date = item.findtext("pubDate", "")
description = item.findtext("description", "")
# Strip HTML tags and decode entities for summary
summary = unescape(re_sub(r"<[^>]+>", "", description))[:200]

posts.append(
{
"title": title,
"link": link,
"published": pub_date,
"summary": summary,
}
)
posts = self._parse_posts(body)

cache.set(self.FEED_CACHE_KEY, posts, self.CACHE_TIMEOUT)
cache.set(self.FEED_STALE_CACHE_KEY, posts, self.STALE_CACHE_TIMEOUT)

return JsonResponse(posts, safe=False)
except Exception:
logger.exception("Failed to fetch blog feed")
stale_posts = cache.get(self.FEED_STALE_CACHE_KEY, [])
if stale_posts:
return JsonResponse(stale_posts, safe=False)
return JsonResponse([], safe=False)


Expand Down
17 changes: 17 additions & 0 deletions src/config/settings/prod.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,22 @@
SECURE_HSTS_PRELOAD = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

# Logging in production: keep scanner noise from flooding logs while preserving errors.
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"loggers": {
"django.request": {
"level": env("DJANGO_REQUEST_LOG_LEVEL", default="ERROR"),
},
"django.security.csrf": {
"level": env("DJANGO_CSRF_LOG_LEVEL", default="ERROR"),
},
"security.probes": {
"level": env("PROBE_LOG_LEVEL", default="WARNING"),
},
},
}

# Email via Acoruss Mailer (direct API integration in apps.core.mailer)
# ACORUSS_MAILER_KEY is read from env in base.py
23 changes: 23 additions & 0 deletions src/static/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -5023,6 +5023,9 @@ html:has(.drawer-toggle:checked) {
.min-w-\[180px\] {
min-width: 180px;
}
.min-w-\[4rem\] {
min-width: 4rem;
}
.max-w-2xl {
max-width: 42rem;
}
Expand Down Expand Up @@ -5373,6 +5376,9 @@ html:has(.drawer-toggle:checked) {
.bg-base-300\/40 {
background-color: var(--fallback-b3,oklch(var(--b3)/0.4));
}
.bg-base-300\/50 {
background-color: var(--fallback-b3,oklch(var(--b3)/0.5));
}
.bg-base-content\/20 {
background-color: var(--fallback-bc,oklch(var(--bc)/0.2));
}
Expand Down Expand Up @@ -5514,6 +5520,9 @@ html:has(.drawer-toggle:checked) {
.\!p-3 {
padding: 0.75rem !important;
}
.p-1 {
padding: 0.25rem;
}
.p-1\.5 {
padding: 0.375rem;
}
Expand Down Expand Up @@ -5634,9 +5643,15 @@ html:has(.drawer-toggle:checked) {
.pb-1 {
padding-bottom: 0.25rem;
}
.pb-10 {
padding-bottom: 2.5rem;
}
.pb-12 {
padding-bottom: 3rem;
}
.pb-16 {
padding-bottom: 4rem;
}
.pb-20 {
padding-bottom: 5rem;
}
Expand Down Expand Up @@ -6310,10 +6325,18 @@ a, button {
padding-bottom: 7rem;
}

.sm\:pb-20 {
padding-bottom: 5rem;
}

.sm\:pb-28 {
padding-bottom: 7rem;
}

.sm\:pt-12 {
padding-top: 3rem;
}

.sm\:pt-4 {
padding-top: 1rem;
}
Expand Down
61 changes: 60 additions & 1 deletion tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

import json
from decimal import Decimal
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, Mock, patch

import pytest
from django.core.cache import cache
from django.test import Client
from django.urls import reverse

from apps.core.views import BlogFeedView


@pytest.mark.django_db
class TestPublicViews:
Expand Down Expand Up @@ -132,3 +135,59 @@ def test_does_not_accept_query_params_injection(self, client: Client) -> None:
response = client.get(self.URL + "?currency=EUR&callback=alert(1)")
# Should either return 200 (ignored params) or the normal response
assert response.status_code in (200, 503)


@pytest.mark.django_db
class TestBlogFeedEndpoint:
"""Test the /api/blog-feed/ endpoint."""

URL = reverse("core:blog_feed")

@pytest.mark.asyncio
@patch("urllib.request.urlopen")
async def test_returns_parsed_posts_on_success(self, mock_urlopen, async_client) -> None:
"""Test that RSS items are parsed and returned as JSON."""
cache.clear()
rss = b"""<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<rss version=\"2.0\"><channel>
<item>
<title>Latest Acoruss Update</title>
<link>https://acoruss.substack.com/p/latest</link>
<pubDate>Mon, 18 May 2026 10:00:00 GMT</pubDate>
<description><![CDATA[<p>New platform launch updates.</p>]]></description>
</item>
</channel></rss>"""

mock_response = Mock()
mock_response.read.return_value = rss
mock_urlopen.return_value = mock_response

response = await async_client.get(self.URL)

assert response.status_code == 200
data = json.loads(response.content)
assert len(data) == 1
assert data[0]["title"] == "Latest Acoruss Update"
assert data[0]["link"] == "https://acoruss.substack.com/p/latest"
assert "New platform launch updates." in data[0]["summary"]

@pytest.mark.asyncio
@patch("urllib.request.urlopen", side_effect=Exception("403"))
async def test_uses_stale_cache_when_upstream_fails(self, _mock_urlopen, async_client) -> None:
"""Test stale cache fallback is returned when feed fetch fails."""
cache.clear()
stale_posts = [
{
"title": "Cached Post",
"link": "https://acoruss.substack.com/p/cached",
"published": "Sun, 17 May 2026 10:00:00 GMT",
"summary": "Cached summary",
}
]
cache.set(BlogFeedView.FEED_STALE_CACHE_KEY, stale_posts, BlogFeedView.STALE_CACHE_TIMEOUT)

response = await async_client.get(self.URL)

assert response.status_code == 200
data = json.loads(response.content)
assert data == stale_posts
Loading