Skip to content
30 changes: 30 additions & 0 deletions frontends/api/src/hooks/learningPaths/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
useLearningPathCreate,
useLearningPathDestroy,
useLearningPathUpdate,
useLearningPathListItemMove,
} from "./index"
import { learningPathKeys } from "./queries"

Expand Down Expand Up @@ -163,6 +164,9 @@ describe("LearningPath CRUD", () => {
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: ["learningPaths", "membershipList"],
})
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: learningResourceKeys.featuredRoot(),
})
})

test("useLearningPathUpdate calls correct API", async () => {
Expand Down Expand Up @@ -190,5 +194,31 @@ describe("LearningPath CRUD", () => {
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: ["learningPaths", "detail", path.id],
})
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: learningResourceKeys.featuredRoot(),
})
})

test("useLearningPathListItemMove invalidates featured and items queries", async () => {
const { path, relationship, pathUrls } = makeData()
setMockResponse.patch(pathUrls.relationshipDetails, relationship)

const { wrapper, queryClient } = setupReactQueryTest()
jest.spyOn(queryClient, "invalidateQueries")

const { result } = renderHook(useLearningPathListItemMove, { wrapper })
result.current.mutate({
parent: path.id,
id: relationship.id,
position: relationship.position,
})
await waitFor(() => expect(result.current.isSuccess).toBe(true))

expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: learningPathKeys.infiniteItemsRoot(path.id),
})
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: learningResourceKeys.featuredRoot(),
})
})
})
10 changes: 10 additions & 0 deletions frontends/api/src/hooks/learningPaths/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
} from "../../generated/v1"
import { learningPathsApi } from "../../clients"
import { learningPathQueries, learningPathKeys } from "./queries"
import { learningResourceKeys } from "../learningResources/queries"
import { useUserHasPermission, Permission } from "api/hooks/user"

const useLearningPathsList = (
Expand Down Expand Up @@ -71,6 +72,9 @@ const useLearningPathUpdate = () => {
queryClient.invalidateQueries({
queryKey: learningPathKeys.detail(vars.id),
})
queryClient.invalidateQueries({
queryKey: learningResourceKeys.featuredRoot(),
})
},
})
}
Expand All @@ -85,6 +89,9 @@ const useLearningPathDestroy = () => {
queryClient.invalidateQueries({
queryKey: learningPathKeys.membershipList(),
})
queryClient.invalidateQueries({
queryKey: learningResourceKeys.featuredRoot(),
})
},
})
}
Expand All @@ -109,6 +116,9 @@ const useLearningPathListItemMove = () => {
queryClient.invalidateQueries({
queryKey: learningPathKeys.infiniteItemsRoot(vars.parent),
})
queryClient.invalidateQueries({
queryKey: learningResourceKeys.featuredRoot(),
})
},
})
}
Expand Down
32 changes: 32 additions & 0 deletions frontends/api/src/hooks/learningResources/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import {
useInfiniteLearningResourceItems,
useLearningResourcesList,
useLearningResourceTopics,
useLearningResourceSetLearningPathRelationships,
} from "./index"
import { learningResourceKeys } from "./queries"
import { learningPathKeys } from "../learningPaths/queries"
import { setMockResponse, urls, makeRequest } from "../../test-utils"
import * as factories from "../../test-utils/factories"
import { UseQueryResult } from "@tanstack/react-query"
Expand Down Expand Up @@ -142,3 +145,32 @@ describe("useLearningResourceTopics", () => {
},
)
})

describe("useLearningResourceSetLearningPathRelationships", () => {
it("invalidates learning path and featured queries", async () => {
const resource = factory.resource()
const url = urls.learningResources.setLearningPathRelationships({
id: resource.id,
})
setMockResponse.patch(url, resource)

const { wrapper, queryClient } = setupReactQueryTest()
jest.spyOn(queryClient, "invalidateQueries")

const { result } = renderHook(
useLearningResourceSetLearningPathRelationships,
{
wrapper,
},
)
result.current.mutate({ id: resource.id })
await waitFor(() => expect(result.current.isSuccess).toBe(true))

expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: learningPathKeys.root,
})
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: learningResourceKeys.featuredRoot(),
})
})
})
3 changes: 3 additions & 0 deletions frontends/api/src/hooks/learningResources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ const useLearningResourceSetLearningPathRelationships = () => {
* Additionally, the lists we've removed from the resource are not easily available.
*/
queryClient.invalidateQueries({ queryKey: learningPathKeys.root })
queryClient.invalidateQueries({
queryKey: learningResourceKeys.featuredRoot(),
})
},
})
}
Expand Down
18 changes: 17 additions & 1 deletion learning_resources/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from django.db import OperationalError
from django.db.models import Q
from django.utils import timezone
from requests.exceptions import RequestException

from learning_resources.constants import LearningResourceType
from learning_resources.etl import ovs, pipelines, youtube
Expand Down Expand Up @@ -57,7 +58,7 @@
from main.celery import app
from main.constants import ISOFORMAT
from main.decorators import cooldown_task
from main.utils import chunks, clear_views_cache, now_in_utc
from main.utils import call_fastly_purge_api, chunks, clear_views_cache, now_in_utc

log = logging.getLogger(__name__)

Expand All @@ -78,6 +79,21 @@ def update_next_start_date_and_prices():
return len(resources)


@app.task(autoretry_for=(RequestException,), retry_backoff=True, max_retries=3)
def clear_featured_caches(channel_names):
"""
Clear cached featured-list data for the given unit channels: the Redis
view cache first, then Fastly pages so re-renders fetch fresh API data.
Channel pages are hard-purged (the editor's refresh must be decisively
fresh); the homepage is soft-purged to keep its stale-while-revalidate
grace for visitors.
"""
clear_views_cache(key_prefix="featured_resources")
for name in channel_names:
call_fastly_purge_api(f"/c/unit/{name}", timeout=5)
call_fastly_purge_api("/", timeout=5, soft=True)


@app.task(acks_late=True, reject_on_worker_lost=True)
@cooldown_task(
wait_time=3600,
Expand Down
28 changes: 28 additions & 0 deletions learning_resources/tasks_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1187,3 +1187,31 @@ def test_cleanup_deleted_content_files_returns_error_on_unexpected_exception(moc
result = cleanup_deleted_content_files()

assert result == "cleanup_deleted_content_files threw an error"


def test_clear_featured_caches(mocker):
"""Clears the Redis prefix first, then purges channel pages hard and homepage soft"""
manager = mocker.Mock()
manager.attach_mock(
mocker.patch("learning_resources.tasks.clear_views_cache"),
"clear_views_cache",
)
manager.attach_mock(
mocker.patch("learning_resources.tasks.call_fastly_purge_api"), "purge"
)

tasks.clear_featured_caches.run(["mitx", "ocw"])

assert manager.mock_calls == [
mocker.call.clear_views_cache(key_prefix="featured_resources"),
mocker.call.purge("/c/unit/mitx", timeout=5),
mocker.call.purge("/c/unit/ocw", timeout=5),
mocker.call.purge("/", timeout=5, soft=True),
]


def test_clear_featured_caches_unconfigured_fastly(mocker, settings):
"""With no Fastly API key the task completes without error"""
settings.FASTLY_API_KEY = ""
mocker.patch("learning_resources.tasks.clear_views_cache")
tasks.clear_featured_caches.run(["mitx"])
52 changes: 50 additions & 2 deletions learning_resources/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from authentication.decorators import blocked_ip_exempt
from channels.constants import ChannelType
from channels.models import Channel
from learning_resources import permissions
from learning_resources import permissions, tasks
from learning_resources.constants import (
GROUP_CONTENT_FILE_CONTENT_VIEWERS,
LearningResourceRelationTypes,
Expand Down Expand Up @@ -479,6 +479,34 @@ def get_queryset(self):
).filter(published=True)


def _enqueue_featured_cache_clear(path_resource_ids):
"""
If any of the given learning paths is a unit channel's featured list,
enqueue a post-commit task to clear the featured-list caches.

Never raises into the caller's request (best-effort, per hq#11979).
"""
channel_names = list(
Channel.objects.filter(
featured_list_id__in=path_resource_ids,
channel_type=ChannelType.unit.name,
).values_list("name", flat=True)
)
if not channel_names:
return

def _delay_clear():
try:
tasks.clear_featured_caches.delay(channel_names)
except Exception:
log.exception(
"Failed to enqueue featured cache clear for channels %s",
channel_names,
)

transaction.on_commit(_delay_clear)


@extend_schema_view(
list=extend_schema(
summary="List", description="Get a paginated list of learning paths"
Expand Down Expand Up @@ -543,6 +571,18 @@ def update(self, request, *_args, **kwargs):
)
return Response(serializer.data)

def perform_update(self, serializer):
super().perform_update(serializer)
_enqueue_featured_cache_clear([serializer.instance.id])

def perform_destroy(self, instance):
# Resolve channel names before the delete (Channel.featured_list is
# on_delete=SET_NULL); the atomic block defers the on_commit enqueue
# until after the delete commits.
with transaction.atomic():
_enqueue_featured_cache_clear([instance.id])
super().perform_destroy(instance)


@extend_schema_view(
list=extend_schema(
Expand Down Expand Up @@ -745,6 +785,9 @@ def learning_paths(self, request, *args, **kwargs): # noqa: ARG002
relation_type=LearningResourceRelationTypes.LEARNING_PATH_ITEMS.value,
parent__resource_type=LearningResourceType.learning_path.name,
)
previous_parent_ids = list(
current_relationships.values_list("parent_id", flat=True)
)
# Remove the resource from lists it WAS in before but is not in now
current_relationships.exclude(parent_id__in=learning_path_ids).delete()
current_parent_lists = current_relationships.values_list("parent_id", flat=True)
Expand All @@ -771,6 +814,7 @@ def learning_paths(self, request, *args, **kwargs): # noqa: ARG002
relation_type=LearningResourceRelationTypes.LEARNING_PATH_ITEMS.value,
position=last_index + 1,
)
_enqueue_featured_cache_clear({*previous_parent_ids, *learning_path_ids})
current_relationships = LearningResourceRelationship.objects.prefetch_related(
Prefetch(
"child",
Expand Down Expand Up @@ -845,6 +889,7 @@ def create(self, request, *args, **kwargs): # noqa: ARG002
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save(parent_id=self.kwargs.get("learning_resource_id"))
_enqueue_featured_cache_clear([self.kwargs.get("learning_resource_id")])

relationship = LearningResourceRelationship.objects.prefetch_related(
Prefetch("child", queryset=LearningResource.objects.for_serialization())
Expand All @@ -857,7 +902,9 @@ def create(self, request, *args, **kwargs): # noqa: ARG002
return Response(response_serializer.data, status=201, headers=headers)

def update(self, request, *args, **kwargs):
return super().update(request, *args, **kwargs)
response = super().update(request, *args, **kwargs)
_enqueue_featured_cache_clear([self.kwargs.get("learning_resource_id")])
return response

def perform_destroy(self, instance):
"""Delete the relationship and update the positions of the remaining items"""
Expand All @@ -868,6 +915,7 @@ def perform_destroy(self, instance):
position__gt=instance.position,
).update(position=F("position") - 1)
instance.delete()
_enqueue_featured_cache_clear([instance.parent_id])


@extend_schema_view(
Expand Down
Loading
Loading