feat(api): add cache + busting for admin
This commit is contained in:
@@ -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,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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user