Skip to content
Closed
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
7 changes: 7 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
Release Notes
=============

Version 0.56.3
--------------

- Learning resource views ETL fix (#2993)
- fix: make direct call to cdn purge (#2983)
- backend for standardizing learning material resources - articles, videos, documents (#2965)

Version 0.56.1 (Released March 02, 2026)
--------------

Expand Down
31 changes: 23 additions & 8 deletions articles/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@

from articles.hooks import get_plugin_manager
from articles.tasks import (
queue_fastly_purge_article,
queue_fastly_purge_articles_list,
PURGE_TIMEOUT_SECONDS,
fastly_purge_articles_list,
fastly_purge_relative_url,
)

log = logging.getLogger(__name__)
Expand All @@ -16,25 +17,39 @@ def purge_article_on_save(article):
Purge the article from the CDN cache when it's saved.

This will trigger a CDN purge for:
- The specific article page (if published and has a slug)
- The articles list page
- The specific article page (if published and has a slug) - attempted immediately
- The articles list page - queued as Celery task

Args:
article: The article instance being saved
"""
# Only purge if the article is published
if article.is_published and article.slug:
log.info(
"Article %s (%s) saved, queueing CDN purge...",
"Article %s (%s) saved, purging CDN...",
article.id,
article.slug,
)

# Purge the specific article page
queue_fastly_purge_article.delay(article.id)
# Try to purge the article immediately with a short timeout
article_url = article.get_url()
try:
article_purge_resp = fastly_purge_relative_url(
article_url, timeout=PURGE_TIMEOUT_SECONDS
)
if article_purge_resp.get("status") == "ok":
log.info("Article purge request processed OK.")
else:
# If immediate purge fails, queue it for Celery
fastly_purge_relative_url.delay(article_url)
log.error("Article purge request failed, enqueued for retry.")
except Exception:
# On any exception (timeout, network error, etc.), queue for Celery
fastly_purge_relative_url.delay(article_url)
log.exception("Article purge request failed, enqueued for retry.")

# Also purge the articles list since it may now include this article
queue_fastly_purge_articles_list.delay()
fastly_purge_articles_list.delay()
else:
log.debug(
"Article %s is not published or has no slug, skipping CDN purge.",
Expand Down
50 changes: 28 additions & 22 deletions articles/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ def test_article_published_actions_triggers_hook(mocker, user):
from articles.models import Article

# Mock CDN purge tasks
mocker.patch("articles.tasks.queue_fastly_purge_article.delay")
mocker.patch("articles.tasks.queue_fastly_purge_articles_list.delay")
mocker.patch("articles.tasks.fastly_purge_relative_url")
mocker.patch("articles.tasks.fastly_purge_articles_list.delay")

# Create a published article
article = Article.objects.create(
Expand Down Expand Up @@ -73,8 +73,9 @@ def test_article_published_actions_logs_execution(mocker, user, caplog):
from articles.models import Article

# Mock CDN purge tasks
mocker.patch("articles.tasks.queue_fastly_purge_article.delay")
mocker.patch("articles.tasks.queue_fastly_purge_articles_list.delay")
mocker.patch("articles.tasks.fastly_purge_relative_url")
mocker.patch("articles.tasks.fastly_purge_relative_url.delay")
mocker.patch("articles.tasks.fastly_purge_articles_list.delay")

# Create a published article
article = Article.objects.create(
Expand Down Expand Up @@ -108,74 +109,79 @@ def test_purge_on_published_article(self, mocker):
from articles.api import purge_article_on_save
from articles.factories import ArticleFactory

mock_purge_article = mocker.patch(
"articles.tasks.queue_fastly_purge_article.delay"
# Mock call_fastly_purge_api to fail so it falls back to Celery task
mocker.patch(
"articles.tasks.call_fastly_purge_api",
side_effect=Exception("Network error"),
)

mock_purge_url = mocker.patch("articles.tasks.fastly_purge_relative_url.delay")
mock_purge_list = mocker.patch(
"articles.tasks.queue_fastly_purge_articles_list.delay"
"articles.tasks.fastly_purge_articles_list.delay"
)

article = ArticleFactory(is_published=True)

purge_article_on_save(article)

mock_purge_article.assert_called_once_with(article.id)
# Should enqueue purge due to exception
mock_purge_url.assert_called_once_with(article.get_url())
mock_purge_list.assert_called_once()

def test_no_purge_on_unpublished_article(self, mocker):
"""Test that CDN purge is NOT triggered for unpublished articles"""
from articles.api import purge_article_on_save
from articles.factories import ArticleFactory

mock_purge_article = mocker.patch(
"articles.tasks.queue_fastly_purge_article.delay"
)
mock_purge_url = mocker.patch("articles.tasks.fastly_purge_relative_url")
mock_purge_list = mocker.patch(
"articles.tasks.queue_fastly_purge_articles_list.delay"
"articles.tasks.fastly_purge_articles_list.delay"
)

article = ArticleFactory(is_published=False)

purge_article_on_save(article)

mock_purge_article.assert_not_called()
mock_purge_url.assert_not_called()
mock_purge_list.assert_not_called()

def test_no_purge_on_article_without_slug(self, mocker):
"""Test that CDN purge is NOT triggered for articles without slug"""
from articles.api import purge_article_on_save
from articles.factories import ArticleFactory

mock_purge_article = mocker.patch(
"articles.tasks.queue_fastly_purge_article.delay"
)
mock_purge_url = mocker.patch("articles.tasks.fastly_purge_relative_url")
mock_purge_list = mocker.patch(
"articles.tasks.queue_fastly_purge_articles_list.delay"
"articles.tasks.fastly_purge_articles_list.delay"
)

article = ArticleFactory(is_published=True)
article.slug = None

purge_article_on_save(article)

mock_purge_article.assert_not_called()
mock_purge_url.assert_not_called()
mock_purge_list.assert_not_called()

def test_purge_on_article_with_slug(self, mocker):
"""Test that CDN purge is triggered when article has slug and is published"""
from articles.api import purge_article_on_save
from articles.factories import ArticleFactory

mock_purge_article = mocker.patch(
"articles.tasks.queue_fastly_purge_article.delay"
# Mock call_fastly_purge_api to fail so it falls back to Celery task
mocker.patch(
"articles.tasks.call_fastly_purge_api",
side_effect=Exception("Network error"),
)

mock_purge_url = mocker.patch("articles.tasks.fastly_purge_relative_url.delay")
mock_purge_list = mocker.patch(
"articles.tasks.queue_fastly_purge_articles_list.delay"
"articles.tasks.fastly_purge_articles_list.delay"
)

article = ArticleFactory(is_published=True, slug="test-article")

purge_article_on_save(article)

mock_purge_article.assert_called_once_with(article.id)
mock_purge_url.assert_called_once_with(article.get_url())
mock_purge_list.assert_called_once()
33 changes: 27 additions & 6 deletions articles/models_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,18 @@


@pytest.mark.django_db
@patch("articles.tasks.queue_fastly_purge_articles_list.delay")
@patch("articles.tasks.queue_fastly_purge_article.delay")
@patch("articles.tasks.fastly_purge_articles_list.delay")
@patch("articles.tasks.fastly_purge_relative_url")
@patch("articles.tasks.fastly_purge_relative_url.delay")
class TestArticleModel:
"""Tests for Article model"""

def test_get_url_with_slug(self, _mock_queue_purge, _mock_queue_list): # noqa: PT019
def test_get_url_with_slug(
self,
_mock_queue_purge_delay, # noqa: PT019
_mock_purge_url, # noqa: PT019
_mock_queue_list, # noqa: PT019
):
"""Test that get_url returns the correct URL for an article with a slug"""
user = User.objects.create_user(username="testuser", email="test@example.com")
article = Article.objects.create(
Expand All @@ -28,7 +34,12 @@ def test_get_url_with_slug(self, _mock_queue_purge, _mock_queue_list): # noqa:

assert article.get_url() == f"/news/{article.slug}"

def test_get_url_without_slug(self, _mock_queue_purge, _mock_queue_list): # noqa: PT019
def test_get_url_without_slug(
self,
_mock_queue_purge_delay, # noqa: PT019
_mock_purge_url, # noqa: PT019
_mock_queue_list, # noqa: PT019
):
"""Test that get_url returns None for an article without a slug"""
user = User.objects.create_user(username="testuser2", email="test2@example.com")
article = Article.objects.create(
Expand All @@ -40,7 +51,12 @@ def test_get_url_without_slug(self, _mock_queue_purge, _mock_queue_list): # noq

assert article.get_url() is None

def test_get_url_with_different_slugs(self, _mock_queue_purge, _mock_queue_list): # noqa: PT019
def test_get_url_with_different_slugs(
self,
_mock_queue_purge_delay, # noqa: PT019
_mock_purge_url, # noqa: PT019
_mock_queue_list, # noqa: PT019
):
"""Test that get_url returns different URLs for different slugs"""
user = User.objects.create_user(username="testuser3", email="test3@example.com")
article1 = Article.objects.create(
Expand All @@ -60,7 +76,12 @@ def test_get_url_with_different_slugs(self, _mock_queue_purge, _mock_queue_list)
assert article2.get_url() == f"/news/{article2.slug}"
assert article1.get_url() != article2.get_url()

def test_slug_generation_on_publish(self, _mock_queue_purge, _mock_queue_list): # noqa: PT019
def test_slug_generation_on_publish(
self,
_mock_queue_purge_delay, # noqa: PT019
_mock_purge_url, # noqa: PT019
_mock_queue_list, # noqa: PT019
):
"""Test that slug is generated when article is published"""
user = User.objects.create_user(username="testuser4", email="test4@example.com")
article = Article.objects.create(
Expand Down
68 changes: 21 additions & 47 deletions articles/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,81 +4,55 @@

from mitol.common.decorators import single_task

from articles.models import Article
from main.celery import app
from main.utils import call_fastly_purge_api

log = logging.getLogger(__name__)

PURGE_TIMEOUT_SECONDS = 5 # 5 seconds


@app.task()
def queue_fastly_purge_article(article_id):
"""
Purges the given article_id from the Fastly cache.
def fastly_purge_relative_url(relative_url, timeout=30):
"""
log.info(f"Processing purge request for article {article_id}") # noqa: G004

try:
article = Article.objects.get(pk=article_id)
except Article.DoesNotExist:
log.exception(f"Article {article_id} not found.") # noqa: G004
return False
Purge the given relative URL from the Fastly cache.

# Only purge if article is published and has a slug
if not article.is_published or not article.slug:
log.info(
f"Article {article_id} is not published or has no slug, skipping purge." # noqa: G004
)
return False
Can be called directly (runs immediately) or via .delay() (enqueued for Celery).

article_url = article.get_url()
log.debug(f"Article URL is {article_url}") # noqa: G004
Args:
relative_url: The relative URL path to purge (e.g., "/news/article-slug/")
timeout: Timeout in seconds for the API request (default: 30)

resp = call_fastly_purge_api(article_url)

if resp and resp.get("status") == "ok":
log.info("Purge request processed OK.")
return True

log.error("Purge request failed.")
return False
Returns:
dict: Response from Fastly API with status
"""
return call_fastly_purge_api(relative_url, timeout=timeout)


@app.task()
def queue_fastly_full_purge():
def fastly_full_purge():
"""
Purges everything from the Fastly cache.

Passing * to the purge API instructs Fastly to purge everything.
"""
log.info("Purging all pages from the Fastly cache...")

resp = call_fastly_purge_api("*")

if resp and resp.get("status") == "ok":
log.info("Purge request processed OK.")
return True

log.error("Purge request failed.")
return False
return call_fastly_purge_api("*")


@app.task()
@single_task(10)
def queue_fastly_purge_articles_list():
def fastly_purge_articles_list():
"""
Purges the articles list page from the Fastly cache.

Can be called directly (runs immediately) or via .delay() (enqueued for Celery).
"""
log.info("Purging articles list page from the Fastly cache...")

# Purge the articles API endpoint
articles_url = "/news"
return call_fastly_purge_api(articles_url)

resp = call_fastly_purge_api(articles_url)

if resp and resp.get("status") == "ok":
log.info("Articles list purge request processed OK.")
return True

log.error("Articles list purge request failed.")
return False
# Backwards compatibility aliases
queue_fastly_purge_articles_list = fastly_purge_articles_list
queue_fastly_full_purge = fastly_full_purge
Loading
Loading