From 67f82a64b8e7d016b79f9560ded947e8dfc05064 Mon Sep 17 00:00:00 2001
From: Musale Martin
Date: Mon, 18 May 2026 20:24:45 +0300
Subject: [PATCH 1/2] feat: enhance blog feed caching and add tests for feed
endpoint
---
src/apps/core/views.py | 75 ++++++++++++++++++++++++++-----------
src/config/settings/prod.py | 17 +++++++++
tests/test_views.py | 61 +++++++++++++++++++++++++++++-
3 files changed, 130 insertions(+), 23 deletions(-)
diff --git a/src/apps/core/views.py b/src/apps/core/views.py
index 6b8cb5b..f351f96 100644
--- a/src/apps/core/views.py
+++ b/src/apps/core/views.py
@@ -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
@@ -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)
diff --git a/src/config/settings/prod.py b/src/config/settings/prod.py
index d1d3bf3..e41274c 100644
--- a/src/config/settings/prod.py
+++ b/src/config/settings/prod.py
@@ -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
diff --git a/tests/test_views.py b/tests/test_views.py
index e12944c..f14ab56 100644
--- a/tests/test_views.py
+++ b/tests/test_views.py
@@ -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:
@@ -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"""
+
+ -
+ Latest Acoruss Update
+ https://acoruss.substack.com/p/latest
+ Mon, 18 May 2026 10:00:00 GMT
+ New platform launch updates.
]]>
+
+ """
+
+ 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
From afd50f6810d09c6e2297f06e5fe15132511a11d8 Mon Sep 17 00:00:00 2001
From: Musale Martin
Date: Mon, 18 May 2026 20:32:16 +0300
Subject: [PATCH 2/2] Added format and linting fixes
---
src/config/settings/prod.py | 26 +++++++++++++-------------
src/static/css/main.css | 23 +++++++++++++++++++++++
2 files changed, 36 insertions(+), 13 deletions(-)
diff --git a/src/config/settings/prod.py b/src/config/settings/prod.py
index e41274c..2c6907a 100644
--- a/src/config/settings/prod.py
+++ b/src/config/settings/prod.py
@@ -24,19 +24,19 @@
# 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"),
- },
- },
+ "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)
diff --git a/src/static/css/main.css b/src/static/css/main.css
index 96c467f..bf83229 100644
--- a/src/static/css/main.css
+++ b/src/static/css/main.css
@@ -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;
}
@@ -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));
}
@@ -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;
}
@@ -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;
}
@@ -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;
}