From 5aeff9c2180d187c4028a5680c328da6fcbb6c77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Gremaud?= Date: Sun, 10 May 2026 04:00:53 +0200 Subject: [PATCH] feat(api): add cache + busting for admin --- polylan_submitter/animations/api.py | 18 +++++++--- polylan_submitter/animations/schemas.py | 2 +- polylan_submitter/noita/api.py | 27 ++++++++++++-- polylan_submitter/polylan_submitter/api.py | 15 ++++++++ .../polylan_submitter/settings.py | 12 +++++++ polylan_submitter/simple_cas_backend.py | 1 - polylan_submitter/src/Noita.vue | 31 ++++++++++++++++ polylan_submitter/src/components/Results.vue | 26 ++++++++++++++ polylan_submitter/submissions/api.py | 14 ++++++++ pyproject.toml | 1 + uv.lock | 36 +++++++++++++++++++ 11 files changed, 175 insertions(+), 8 deletions(-) diff --git a/polylan_submitter/animations/api.py b/polylan_submitter/animations/api.py index d067291..ea2f193 100644 --- a/polylan_submitter/animations/api.py +++ b/polylan_submitter/animations/api.py @@ -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 diff --git a/polylan_submitter/animations/schemas.py b/polylan_submitter/animations/schemas.py index 625f90e..d064bc6 100644 --- a/polylan_submitter/animations/schemas.py +++ b/polylan_submitter/animations/schemas.py @@ -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): diff --git a/polylan_submitter/noita/api.py b/polylan_submitter/noita/api.py index 98ebd1d..839693c 100644 --- a/polylan_submitter/noita/api.py +++ b/polylan_submitter/noita/api.py @@ -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, diff --git a/polylan_submitter/polylan_submitter/api.py b/polylan_submitter/polylan_submitter/api.py index 8957e4e..747e093 100644 --- a/polylan_submitter/polylan_submitter/api.py +++ b/polylan_submitter/polylan_submitter/api.py @@ -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): diff --git a/polylan_submitter/polylan_submitter/settings.py b/polylan_submitter/polylan_submitter/settings.py index 95ada45..3642c36 100644 --- a/polylan_submitter/polylan_submitter/settings.py +++ b/polylan_submitter/polylan_submitter/settings.py @@ -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", diff --git a/polylan_submitter/simple_cas_backend.py b/polylan_submitter/simple_cas_backend.py index 43a1782..94b6fa2 100644 --- a/polylan_submitter/simple_cas_backend.py +++ b/polylan_submitter/simple_cas_backend.py @@ -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): diff --git a/polylan_submitter/src/Noita.vue b/polylan_submitter/src/Noita.vue index fc4e6af..180f1ec 100644 --- a/polylan_submitter/src/Noita.vue +++ b/polylan_submitter/src/Noita.vue @@ -11,6 +11,7 @@ const userInfo = ref({ rank: null as number | null, score: 0, runsSubmitted: 0, + isStaff: false, }); const uploadedFiles = ref([]); @@ -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(() => { View Full Leaderboard + + diff --git a/polylan_submitter/src/components/Results.vue b/polylan_submitter/src/components/Results.vue index 6723f92..e121d69 100644 --- a/polylan_submitter/src/components/Results.vue +++ b/polylan_submitter/src/components/Results.vue @@ -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(() => {

Puzzles Solved

{{ userInfo.puzzlesSolved }}

+ + diff --git a/polylan_submitter/submissions/api.py b/polylan_submitter/submissions/api.py index 3085583..4199416 100644 --- a/polylan_submitter/submissions/api.py +++ b/polylan_submitter/submissions/api.py @@ -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"} diff --git a/pyproject.toml b/pyproject.toml index 5706cce..fb24227 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "psycopg>=3.2.13", "sentry-sdk[django]>=2.59.0", "furl>=2.1.4", + "django-redis>=6.0.0", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index cb600f4..0881fdc 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, ] +[[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]] name = "certifi" 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" }, ] +[[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]] name = "django-shinobi" version = "1.4.0" @@ -727,6 +749,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "django" }, + { name = "django-redis" }, { name = "django-shinobi" }, { name = "django-vite" }, { name = "furl" }, @@ -757,6 +780,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "django", specifier = ">=5.2.7" }, + { name = "django-redis", specifier = ">=6.0.0" }, { name = "django-shinobi", specifier = ">=1.4.0" }, { name = "django-vite", specifier = ">=3.1.0" }, { 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" }, ] +[[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]] name = "requests" version = "2.32.5"