feat(api): add cache + busting for admin

This commit is contained in:
Loïc Gremaud 2026-05-10 04:00:53 +02:00
parent 2264401a91
commit 5aeff9c218
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
11 changed files with 175 additions and 8 deletions

View File

@ -1,6 +1,7 @@
from collections import defaultdict from collections import defaultdict
from django.http import HttpRequest from django.http import HttpRequest
from ninja import Router from ninja import Router
from django.core.cache import cache
from accounts.models import CustomUser from accounts.models import CustomUser
from animations.schemas import RankingSchema from animations.schemas import RankingSchema
@ -12,6 +13,12 @@ router = Router()
@router.get("results", response=RankingSchema) @router.get("results", response=RankingSchema)
def results(request: HttpRequest) -> dict: 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_userid = defaultdict(list)
responses_by_puzzleid = 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) responses, key=lambda x: (x.rank_points is None, x.rank_points or 0)
) )
return { data = {
"users": CustomUser.objects.filter(pk__in=responses_by_userid.keys()), "users": list(CustomUser.objects.filter(pk__in=responses_by_userid.keys())),
"puzzles": SteamCollectionItem.objects.all(), "puzzles": list(SteamCollectionItem.objects.all()),
"responses_by_userid": responses_by_userid, "responses_by_userid": dict(responses_by_userid),
"ranking_by_puzzle": ranking, "ranking_by_puzzle": ranking,
} }
cache.set("api:results:results", data, 300)
return data

View File

@ -1,7 +1,7 @@
from ninja import ModelSchema, Schema from ninja import ModelSchema, Schema
from submissions.models import PuzzleResponse from submissions.models import PuzzleResponse
from submissions.schemas import SteamCollectionItemOut, UserInfoOut from submissions.schemas import SteamCollectionItemOut
class PuzzleResponseRankingOut(ModelSchema): class PuzzleResponseRankingOut(ModelSchema):

View File

@ -1,5 +1,6 @@
from django.http import HttpRequest from django.http import HttpRequest
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.cache import cache
from django.db.models import ( from django.db.models import (
F, F,
Case, Case,
@ -32,6 +33,11 @@ def get_my_objectives(request: HttpRequest):
@router.get("results", response=ResultsOut) @router.get("results", response=ResultsOut)
def get_results(request: HttpRequest): 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. 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") for obj in user_objectives.order_by("-total_points")
] ]
return { data = {
"total_score": total_score, "total_score": total_score,
"objectives": objectives_with_points, "objectives": objectives_with_points,
} }
cache.set(f"api:noita:results:{request.user.id}", data, 300)
return data
@router.get("leaderboard", response=LeaderboardOut) @router.get("leaderboard", response=LeaderboardOut)
def get_leaderboard(request: HttpRequest): 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. 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 from django.contrib.auth import get_user_model
User = get_user_model() User = get_user_model()
@ -174,7 +189,7 @@ def get_leaderboard(request: HttpRequest):
.order_by("rank") .order_by("rank")
) )
return { data = {
"leaderboard": [ "leaderboard": [
{ {
"rank": entry["rank"], "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}) @router.post("submit", response={200: NoitaSubmissionOut, 400: dict})
def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)): def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
@ -232,6 +250,11 @@ def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
except Exception: except Exception:
pass pass
# Invalidate caches on successful submission
if submission.user:
cache.delete(f"api:noita:results:{submission.user.id}")
cache.delete("api:noita:leaderboard")
return { return {
"id": str(submission.id), "id": str(submission.id),
"user_id": submission.user_id, "user_id": submission.user_id,

View File

@ -1,4 +1,6 @@
from ninja import NinjaAPI 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.api import router as submissions_router
from submissions.schemas import UserInfoOut from submissions.schemas import UserInfoOut
from animations.api import router as results_router from animations.api import router as results_router
@ -41,6 +43,19 @@ def health_check(request):
return {"status": "healthy", "service": "polylan-submitter-api"} 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 # User info endpoint
@api.get("/user", response=UserInfoOut) @api.get("/user", response=UserInfoOut)
def get_user_info(request): def get_user_info(request):

View File

@ -145,6 +145,18 @@ MEDIA_ROOT = BASE_DIR / "media"
FILE_UPLOAD_MAX_MEMORY_SIZE = 256 * 1024 * 1024 # 256MB FILE_UPLOAD_MAX_MEMORY_SIZE = 256 * 1024 * 1024 # 256MB
DATA_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 file types for submissions
ALLOWED_SUBMISSION_TYPES = [ ALLOWED_SUBMISSION_TYPES = [
"image/jpeg", "image/jpeg",

View File

@ -7,7 +7,6 @@ import requests
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.backends import BaseBackend from django.contrib.auth.backends import BaseBackend
from furl import furl
class SimpleCASBackend(BaseBackend): class SimpleCASBackend(BaseBackend):

View File

@ -11,6 +11,7 @@ const userInfo = ref({
rank: null as number | null, rank: null as number | null,
score: 0, score: 0,
runsSubmitted: 0, runsSubmitted: 0,
isStaff: false,
}); });
const uploadedFiles = ref<File[]>([]); 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 () => { const loadUserData = async () => {
// Get user info first // Get user info first
try { try {
@ -156,6 +181,7 @@ const loadUserData = async () => {
const user = await response.json(); const user = await response.json();
if (user.is_authenticated) { if (user.is_authenticated) {
userInfo.value.username = user.username; userInfo.value.username = user.username;
userInfo.value.isStaff = user.is_staff || false;
} }
} }
} catch (error) { } catch (error) {
@ -237,6 +263,11 @@ onMounted(() => {
<i class="mdi mdi-trophy mr-1"></i> <i class="mdi mdi-trophy mr-1"></i>
View Full Leaderboard View Full Leaderboard
</button> </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> </div>
</div> </div>

View File

@ -50,6 +50,7 @@ const userInfo = ref({
rank: null as number | null, rank: null as number | null,
totalPoints: 0, totalPoints: 0,
puzzlesSolved: 0, puzzlesSolved: 0,
isStaff: false,
}); });
const fetchResults = async () => { const fetchResults = async () => {
@ -106,6 +107,25 @@ const togglePuzzleExpanded = (puzzleId: number) => {
expandedPuzzleId.value = expandedPuzzleId.value === puzzleId ? null : puzzleId; 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 () => { const loadUserData = async () => {
try { try {
const response = await fetch("/api/user"); const response = await fetch("/api/user");
@ -113,6 +133,7 @@ const loadUserData = async () => {
const user = await response.json(); const user = await response.json();
if (user.is_authenticated) { if (user.is_authenticated) {
userInfo.value.username = user.username; userInfo.value.username = user.username;
userInfo.value.isStaff = user.is_staff || false;
await fetchResults(); await fetchResults();
@ -178,6 +199,11 @@ onMounted(() => {
<p class="text-sm text-base-content/70 mb-1">Puzzles Solved</p> <p class="text-sm text-base-content/70 mb-1">Puzzles Solved</p>
<p class="text-2xl font-bold">{{ userInfo.puzzlesSolved }}</p> <p class="text-2xl font-bold">{{ userInfo.puzzlesSolved }}</p>
</div> </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> </div>
</div> </div>

View File

@ -3,6 +3,7 @@ from ninja.files import UploadedFile
from ninja.pagination import paginate from ninja.pagination import paginate
from django.db import transaction from django.db import transaction
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.cache import cache
from django.utils import timezone from django.utils import timezone
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from typing import List from typing import List
@ -165,6 +166,9 @@ def create_submission(
"responses__files", "responses__puzzle" "responses__files", "responses__puzzle"
).get(id=submission.id) ).get(id=submission.id)
# Invalidate results cache on successful submission
cache.delete("api:results:results")
return submission return submission
except Exception as e: except Exception as e:
@ -201,6 +205,9 @@ def validate_response(request, response_id: int, data: ValidationIn):
response.save() response.save()
# Invalidate results cache when a response is validated
cache.delete("api:results:results")
return response return response
@ -256,6 +263,9 @@ def validate_submission(request, submission_id: str):
"responses__files", "responses__puzzle" "responses__files", "responses__puzzle"
).get(id=submission.id) ).get(id=submission.id)
# Invalidate results cache when submission is validated
cache.delete("api:results:results")
return submission return submission
@ -268,6 +278,10 @@ def delete_submission(request, submission_id: str):
submission = get_object_or_404(Submission, id=submission_id) submission = get_object_or_404(Submission, id=submission_id)
submission.delete() submission.delete()
# Invalidate results cache when submission is deleted
cache.delete("api:results:results")
return {"detail": "Submission deleted successfully"} return {"detail": "Submission deleted successfully"}

View File

@ -16,6 +16,7 @@ dependencies = [
"psycopg>=3.2.13", "psycopg>=3.2.13",
"sentry-sdk[django]>=2.59.0", "sentry-sdk[django]>=2.59.0",
"furl>=2.1.4", "furl>=2.1.4",
"django-redis>=6.0.0",
] ]
[dependency-groups] [dependency-groups]

36
uv.lock
View File

@ -37,6 +37,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" }, { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" },
] ]
[[package]]
name = "async-timeout"
version = "5.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2025.10.5" version = "2025.10.5"
@ -197,6 +206,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" }, { url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" },
] ]
[[package]]
name = "django-redis"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "redis" },
]
sdist = { url = "https://files.pythonhosted.org/packages/08/53/dbcfa1e528e0d6c39947092625b2c89274b5d88f14d357cee53c4d6dbbd4/django_redis-6.0.0.tar.gz", hash = "sha256:2d9cb12a20424a4c4dde082c6122f486628bae2d9c2bee4c0126a4de7fda00dd", size = 56904, upload-time = "2025-06-17T18:15:46.376Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/79/055dfcc508cfe9f439d9f453741188d633efa9eab90fc78a67b0ab50b137/django_redis-6.0.0-py3-none-any.whl", hash = "sha256:20bf0063a8abee567eb5f77f375143c32810c8700c0674ced34737f8de4e36c0", size = 33687, upload-time = "2025-06-17T18:15:34.165Z" },
]
[[package]] [[package]]
name = "django-shinobi" name = "django-shinobi"
version = "1.4.0" version = "1.4.0"
@ -727,6 +749,7 @@ version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "django" }, { name = "django" },
{ name = "django-redis" },
{ name = "django-shinobi" }, { name = "django-shinobi" },
{ name = "django-vite" }, { name = "django-vite" },
{ name = "furl" }, { name = "furl" },
@ -757,6 +780,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "django", specifier = ">=5.2.7" }, { name = "django", specifier = ">=5.2.7" },
{ name = "django-redis", specifier = ">=6.0.0" },
{ name = "django-shinobi", specifier = ">=1.4.0" }, { name = "django-shinobi", specifier = ">=1.4.0" },
{ name = "django-vite", specifier = ">=3.1.0" }, { name = "django-vite", specifier = ">=3.1.0" },
{ name = "furl", specifier = ">=2.1.4" }, { name = "furl", specifier = ">=2.1.4" },
@ -1073,6 +1097,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
] ]
[[package]]
name = "redis"
version = "7.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "async-timeout", marker = "python_full_version < '3.11.3'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" },
]
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.32.5" version = "2.32.5"