diff --git a/backend/reviews/admin.py b/backend/reviews/admin.py
index ffc35f397e..f09187ca24 100644
--- a/backend/reviews/admin.py
+++ b/backend/reviews/admin.py
@@ -62,10 +62,7 @@ def with_pct(counts_dict):
.order_by("speaker__gender")
)
gender_counts = with_pct(
- {
- item["speaker__gender"] or "unknown": item["count"]
- for item in gender_stats
- }
+ {item["speaker__gender"] or "unknown": item["count"] for item in gender_stats}
)
level_stats = (
@@ -87,9 +84,7 @@ def with_pct(counts_dict):
)
speaker_level_stats = (
- qs.values("speaker_level")
- .annotate(count=Count("id"))
- .order_by("speaker_level")
+ qs.values("speaker_level").annotate(count=Count("id")).order_by("speaker_level")
)
speaker_level_counts = with_pct(
{item["speaker_level"]: item["count"] for item in speaker_level_stats}
@@ -455,6 +450,7 @@ def review_recap_compute_analysis_view(self, request, review_session_id):
conference = review_session.conference
accepted_submissions = list(self._get_accepted_submissions(conference))
force_recompute = request.GET.get("recompute") == "1"
+ check_only = request.GET.get("check") == "1"
from django.core.cache import cache
@@ -471,6 +467,9 @@ def review_recap_compute_analysis_view(self, request, review_session_id):
if cached_result is not None:
return JsonResponse(cached_result)
+ if check_only:
+ return JsonResponse({"status": "empty"})
+
# Use cache.add as a lock to prevent duplicate task dispatch.
# Short TTL so lock auto-expires if the worker is killed before cleanup.
computing_key = f"{combined_cache_key}:computing"
diff --git a/backend/reviews/templates/reviews-recap.html b/backend/reviews/templates/reviews-recap.html
index 8c8e2bbe24..595c499f36 100644
--- a/backend/reviews/templates/reviews-recap.html
+++ b/backend/reviews/templates/reviews-recap.html
@@ -682,6 +682,22 @@
🔗 Similar Talks
btn.addEventListener('click', function() { fetchAnalysis(false); });
recomputeBtn.addEventListener('click', function() { fetchAnalysis(true); });
+
+ // Auto-load cached results on page load (check only, don't trigger computation)
+ fetch(computeUrl + '?check=1', {
+ headers: { 'X-Requested-With': 'XMLHttpRequest' }
+ })
+ .then(function(response) {
+ if (!response.ok) return;
+ return response.json();
+ })
+ .then(function(data) {
+ if (!data || data.status === 'processing' || data.status === 'error' || data.status === 'empty') return;
+ handleResult(data);
+ })
+ .catch(function() {
+ // Silently ignore errors on auto-load
+ });
})();
{% endblock %}
diff --git a/backend/reviews/tests/test_recap.py b/backend/reviews/tests/test_recap.py
index e2176430dd..366209378b 100644
--- a/backend/reviews/tests/test_recap.py
+++ b/backend/reviews/tests/test_recap.py
@@ -174,10 +174,7 @@ def test_recap_view_redirects_when_shortlist_not_visible(rf, mocker):
response = admin.review_recap_view(request, review_session.id)
assert response.status_code == 302
- assert (
- response.url
- == f"/admin/reviews/reviewsession/{review_session.id}/change/"
- )
+ assert response.url == f"/admin/reviews/reviewsession/{review_session.id}/change/"
# --- review_recap_compute_analysis_view tests ---
@@ -194,9 +191,7 @@ def cache_get_side_effect(key):
mock_cache_get = mocker.patch(
"django.core.cache.cache.get", side_effect=cache_get_side_effect
)
- mock_cache_add = mocker.patch(
- "django.core.cache.cache.add", return_value=True
- )
+ mock_cache_add = mocker.patch("django.core.cache.cache.add", return_value=True)
mocker.patch("django.core.cache.cache.set")
mocker.patch("django.core.cache.cache.delete")
mock_task = mocker.patch("reviews.tasks.compute_recap_analysis.apply_async")
@@ -573,6 +568,56 @@ def cache_get_side_effect(key):
mock_check.assert_not_called()
+def test_compute_analysis_view_check_only_returns_empty_on_cache_miss(rf, mocker):
+ user, conference, review_session, submissions = _create_recap_setup()
+
+ _, _, mock_task, mock_check = _mock_analysis_deps(mocker, cache_return=None)
+
+ request = rf.get("/?check=1")
+ request.user = user
+
+ admin = ReviewSessionAdmin(ReviewSession, AdminSite())
+ response = admin.review_recap_compute_analysis_view(request, review_session.id)
+
+ data = json.loads(response.content)
+ assert data == {"status": "empty"}
+
+ # Task should NOT be dispatched in check-only mode
+ mock_task.assert_not_called()
+ mock_check.assert_not_called()
+
+
+def test_compute_analysis_view_check_only_returns_cached_result(rf, mocker):
+ user, conference, review_session, submissions = _create_recap_setup()
+ sub1, sub2 = submissions
+
+ cached_data = {
+ "submissions_list": [
+ {
+ "id": sub1.id,
+ "title": str(sub1.title),
+ "type": sub1.type.name,
+ "speaker": sub1.speaker.display_name,
+ "similar": [],
+ },
+ ],
+ "topic_clusters": {"topics": [], "outliers": [], "submission_topics": {}},
+ }
+
+ _, _, mock_task, _ = _mock_analysis_deps(mocker, cache_return=cached_data)
+
+ request = rf.get("/?check=1")
+ request.user = user
+
+ admin = ReviewSessionAdmin(ReviewSession, AdminSite())
+ response = admin.review_recap_compute_analysis_view(request, review_session.id)
+
+ data = json.loads(response.content)
+ assert data["submissions_list"][0]["id"] == sub1.id
+
+ mock_task.assert_not_called()
+
+
def test_error_cache_ttl_is_shorter_than_result_ttl():
from reviews.tasks import ERROR_CACHE_TTL, RESULT_CACHE_TTL