feat(api): add cache + busting for admin

This commit is contained in:
2026-05-10 04:00:53 +02:00
parent 2264401a91
commit 5aeff9c218
11 changed files with 175 additions and 8 deletions
+14 -4
View File
@@ -1,6 +1,7 @@
from collections import defaultdict
from django.http import HttpRequest
from ninja import Router
from django.core.cache import cache
from accounts.models import CustomUser
from animations.schemas import RankingSchema
@@ -12,6 +13,12 @@ router = Router()
@router.get("results", response=RankingSchema)
def results(request: HttpRequest) -> dict:
cache_key = "api:results:results"
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data
responses_by_userid = defaultdict(list)
responses_by_puzzleid = defaultdict(list)
@@ -30,9 +37,12 @@ def results(request: HttpRequest) -> dict:
responses, key=lambda x: (x.rank_points is None, x.rank_points or 0)
)
return {
"users": CustomUser.objects.filter(pk__in=responses_by_userid.keys()),
"puzzles": SteamCollectionItem.objects.all(),
"responses_by_userid": responses_by_userid,
data = {
"users": list(CustomUser.objects.filter(pk__in=responses_by_userid.keys())),
"puzzles": list(SteamCollectionItem.objects.all()),
"responses_by_userid": dict(responses_by_userid),
"ranking_by_puzzle": ranking,
}
cache.set("api:results:results", data, 300)
return data
+1 -1
View File
@@ -1,7 +1,7 @@
from ninja import ModelSchema, Schema
from submissions.models import PuzzleResponse
from submissions.schemas import SteamCollectionItemOut, UserInfoOut
from submissions.schemas import SteamCollectionItemOut
class PuzzleResponseRankingOut(ModelSchema):
+25 -2
View File
@@ -1,5 +1,6 @@
from django.http import HttpRequest
from django.core.files.base import ContentFile
from django.core.cache import cache
from django.db.models import (
F,
Case,
@@ -32,6 +33,11 @@ def get_my_objectives(request: HttpRequest):
@router.get("results", response=ResultsOut)
def get_results(request: HttpRequest):
cache_key = f"api:noita:results:{request.user.id}"
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data
"""
Get the user's score based on their objectives.
@@ -92,11 +98,14 @@ def get_results(request: HttpRequest):
for obj in user_objectives.order_by("-total_points")
]
return {
data = {
"total_score": total_score,
"objectives": objectives_with_points,
}
cache.set(f"api:noita:results:{request.user.id}", data, 300)
return data
@router.get("leaderboard", response=LeaderboardOut)
def get_leaderboard(request: HttpRequest):
@@ -105,6 +114,12 @@ def get_leaderboard(request: HttpRequest):
Uses Window functions to rank users by their total score in descending order.
"""
cache_key = "api:noita:leaderboard"
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data
from django.contrib.auth import get_user_model
User = get_user_model()
@@ -174,7 +189,7 @@ def get_leaderboard(request: HttpRequest):
.order_by("rank")
)
return {
data = {
"leaderboard": [
{
"rank": entry["rank"],
@@ -186,6 +201,9 @@ def get_leaderboard(request: HttpRequest):
]
}
cache.set("api:noita:leaderboard", data, 300)
return data
@router.post("submit", response={200: NoitaSubmissionOut, 400: dict})
def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
@@ -232,6 +250,11 @@ def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
except Exception:
pass
# Invalidate caches on successful submission
if submission.user:
cache.delete(f"api:noita:results:{submission.user.id}")
cache.delete("api:noita:leaderboard")
return {
"id": str(submission.id),
"user_id": submission.user_id,
@@ -1,4 +1,6 @@
from ninja import NinjaAPI
from django.core.cache import cache
from django.http import HttpRequest
from submissions.api import router as submissions_router
from submissions.schemas import UserInfoOut
from animations.api import router as results_router
@@ -41,6 +43,19 @@ def health_check(request):
return {"status": "healthy", "service": "polylan-submitter-api"}
# Cache management endpoint
@api.post("/cache/clear")
def clear_cache(request: HttpRequest):
"""Clear all API caches (admin only)"""
if not request.user.is_authenticated or not request.user.is_staff:
return 403, {"detail": "Admin access required"}
keys = cache.keys("api:*")
cache.delete_many(keys)
return {"detail": f"Cleared {len(keys)} cache entries"}
# User info endpoint
@api.get("/user", response=UserInfoOut)
def get_user_info(request):
@@ -145,6 +145,18 @@ MEDIA_ROOT = BASE_DIR / "media"
FILE_UPLOAD_MAX_MEMORY_SIZE = 256 * 1024 * 1024 # 256MB
DATA_UPLOAD_MAX_MEMORY_SIZE = 256 * 1024 * 1024 # 256MB
# Caching Configuration
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/11",
"TIMEOUT": 300, # 5 minutes default
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
}
}
# Allowed file types for submissions
ALLOWED_SUBMISSION_TYPES = [
"image/jpeg",
-1
View File
@@ -7,7 +7,6 @@ import requests
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import BaseBackend
from furl import furl
class SimpleCASBackend(BaseBackend):
+31
View File
@@ -11,6 +11,7 @@ const userInfo = ref({
rank: null as number | null,
score: 0,
runsSubmitted: 0,
isStaff: false,
});
const uploadedFiles = ref<File[]>([]);
@@ -148,6 +149,30 @@ const fetchLeaderboard = async () => {
}
};
const clearCache = async () => {
try {
const response = await fetch("/api/cache/clear", {
method: "POST",
});
if (response.ok) {
alert("Cache cleared successfully!");
// Refresh data after clearing cache
await Promise.all([
fetchObjectives(),
fetchUserResults(),
fetchLeaderboard(),
]);
} else {
const error = await response.json();
alert(`Error clearing cache: ${error.detail || "Unknown error"}`);
}
} catch (error) {
console.error("Error clearing cache:", error);
alert("Error clearing cache. Please try again.");
}
};
const loadUserData = async () => {
// Get user info first
try {
@@ -156,6 +181,7 @@ const loadUserData = async () => {
const user = await response.json();
if (user.is_authenticated) {
userInfo.value.username = user.username;
userInfo.value.isStaff = user.is_staff || false;
}
}
} catch (error) {
@@ -237,6 +263,11 @@ onMounted(() => {
<i class="mdi mdi-trophy mr-1"></i>
View Full Leaderboard
</button>
<button v-if="userInfo.isStaff" @click="clearCache" class="btn btn-error btn-sm w-full mt-3">
<i class="mdi mdi-cache-clear mr-1"></i>
Clear Cache
</button>
</div>
</div>
</div>
@@ -50,6 +50,7 @@ const userInfo = ref({
rank: null as number | null,
totalPoints: 0,
puzzlesSolved: 0,
isStaff: false,
});
const fetchResults = async () => {
@@ -106,6 +107,25 @@ const togglePuzzleExpanded = (puzzleId: number) => {
expandedPuzzleId.value = expandedPuzzleId.value === puzzleId ? null : puzzleId;
};
const clearCache = async () => {
try {
const response = await fetch("/api/cache/clear", {
method: "POST",
});
if (response.ok) {
alert("Cache cleared successfully!");
await fetchResults();
} else {
const error = await response.json();
alert(`Error clearing cache: ${error.detail || "Unknown error"}`);
}
} catch (error) {
console.error("Error clearing cache:", error);
alert("Error clearing cache. Please try again.");
}
};
const loadUserData = async () => {
try {
const response = await fetch("/api/user");
@@ -113,6 +133,7 @@ const loadUserData = async () => {
const user = await response.json();
if (user.is_authenticated) {
userInfo.value.username = user.username;
userInfo.value.isStaff = user.is_staff || false;
await fetchResults();
@@ -178,6 +199,11 @@ onMounted(() => {
<p class="text-sm text-base-content/70 mb-1">Puzzles Solved</p>
<p class="text-2xl font-bold">{{ userInfo.puzzlesSolved }}</p>
</div>
<button v-if="userInfo.isStaff" @click="clearCache" class="btn btn-error btn-sm w-full mt-6">
<i class="mdi mdi-cache-clear mr-1"></i>
Clear Cache
</button>
</div>
</div>
</div>
+14
View File
@@ -3,6 +3,7 @@ from ninja.files import UploadedFile
from ninja.pagination import paginate
from django.db import transaction
from django.core.files.base import ContentFile
from django.core.cache import cache
from django.utils import timezone
from django.shortcuts import get_object_or_404
from typing import List
@@ -165,6 +166,9 @@ def create_submission(
"responses__files", "responses__puzzle"
).get(id=submission.id)
# Invalidate results cache on successful submission
cache.delete("api:results:results")
return submission
except Exception as e:
@@ -201,6 +205,9 @@ def validate_response(request, response_id: int, data: ValidationIn):
response.save()
# Invalidate results cache when a response is validated
cache.delete("api:results:results")
return response
@@ -256,6 +263,9 @@ def validate_submission(request, submission_id: str):
"responses__files", "responses__puzzle"
).get(id=submission.id)
# Invalidate results cache when submission is validated
cache.delete("api:results:results")
return submission
@@ -268,6 +278,10 @@ def delete_submission(request, submission_id: str):
submission = get_object_or_404(Submission, id=submission_id)
submission.delete()
# Invalidate results cache when submission is deleted
cache.delete("api:results:results")
return {"detail": "Submission deleted successfully"}