Compare commits

...

2 Commits

11 changed files with 271 additions and 42 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(...)):
@ -203,12 +221,6 @@ def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
allowed_types = [ allowed_types = [
"text/plain", "text/plain",
"text/x-log", "text/x-log",
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"video/mp4",
"video/webm",
] ]
if file.content_type not in allowed_types: if file.content_type not in allowed_types:
@ -238,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>
@ -256,13 +287,13 @@ onMounted(() => {
isDragover ? 'border-primary bg-primary/10' : 'border-base-300 hover:border-primary' isDragover ? 'border-primary bg-primary/10' : 'border-base-300 hover:border-primary'
]"> ]">
<input type="file" multiple @change="handleFileUpload" class="hidden" id="file-upload" <input type="file" multiple @change="handleFileUpload" class="hidden" id="file-upload"
accept="video/*,image/*" /> accept="text/plain,text/x-log" />
<label for="file-upload" class="cursor-pointer flex flex-col items-center gap-3"> <label for="file-upload" class="cursor-pointer flex flex-col items-center gap-3">
<i <i
:class="['mdi text-4xl', isDragover ? 'mdi-cloud-check text-primary' : 'mdi-file-upload text-base-content/50']"></i> :class="['mdi text-4xl', isDragover ? 'mdi-cloud-check text-primary' : 'mdi-file-upload text-base-content/50']"></i>
<div> <div>
<p class="font-semibold">Click to upload or drag and drop</p> <p class="font-semibold">Click to upload or drag and drop</p>
<p class="text-sm text-base-content/70">Video or image files (MP4, PNG, etc.)</p> <p class="text-sm text-base-content/70">The log file <code>polylan_mod_log.txt</code></p>
</div> </div>
</label> </label>
</div> </div>
@ -353,10 +384,7 @@ onMounted(() => {
<i class="mdi mdi-trophy text-yellow-500 mr-2"></i> <i class="mdi mdi-trophy text-yellow-500 mr-2"></i>
Global Leaderboard Global Leaderboard
</h3> </h3>
<button <button @click="isLeaderboardModalOpen = false" class="btn btn-sm btn-circle btn-ghost">
@click="isLeaderboardModalOpen = false"
class="btn btn-sm btn-circle btn-ghost"
>
<i class="mdi mdi-close"></i> <i class="mdi mdi-close"></i>
</button> </button>
</div> </div>
@ -373,16 +401,10 @@ onMounted(() => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr v-for="entry in leaderboard" :key="entry.username"
v-for="entry in leaderboard" :class="{ 'bg-primary/20': entry.username === userInfo.username }">
:key="entry.username"
:class="{ 'bg-primary/20': entry.username === userInfo.username }"
>
<td class="font-bold"> <td class="font-bold">
<span <span v-if="entry.rank === 1" class="badge badge-warning badge-lg">
v-if="entry.rank === 1"
class="badge badge-warning badge-lg"
>
🏆 #{{ entry.rank }} 🏆 #{{ entry.rank }}
</span> </span>
<span v-else-if="entry.rank === 2" class="badge badge-lg"> <span v-else-if="entry.rank === 2" class="badge badge-lg">
@ -395,10 +417,7 @@ onMounted(() => {
</td> </td>
<td class="font-medium"> <td class="font-medium">
{{ entry.username }} {{ entry.username }}
<span <span v-if="entry.username === userInfo.username" class="badge badge-primary badge-sm ml-2">
v-if="entry.username === userInfo.username"
class="badge badge-primary badge-sm ml-2"
>
You You
</span> </span>
</td> </td>

View File

@ -45,6 +45,13 @@ const isLoading = ref(true);
const resultsData = ref<ResultsData | null>(null); const resultsData = ref<ResultsData | null>(null);
const selectedTab = ref<"overall" | "byPuzzle">("overall"); const selectedTab = ref<"overall" | "byPuzzle">("overall");
const expandedPuzzleId = ref<number | null>(null); const expandedPuzzleId = ref<number | null>(null);
const userInfo = ref({
username: "Player",
rank: null as number | null,
totalPoints: 0,
puzzlesSolved: 0,
isStaff: false,
});
const fetchResults = async () => { const fetchResults = async () => {
isLoading.value = true; isLoading.value = true;
@ -100,23 +107,120 @@ 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 () => {
try {
const response = await fetch("/api/user");
if (response.ok) {
const user = await response.json();
if (user.is_authenticated) {
userInfo.value.username = user.username;
userInfo.value.isStaff = user.is_staff || false;
await fetchResults();
// Calculate user's rank and stats
const ranking = getOverallRanking();
const userRankIndex = ranking.findIndex((u) => u.username === user.username);
if (userRankIndex !== -1) {
userInfo.value.rank = userRankIndex + 1;
userInfo.value.totalPoints = ranking[userRankIndex].totalPoints;
userInfo.value.puzzlesSolved = ranking[userRankIndex].puzzlesSolved;
}
}
}
} catch (error) {
console.error("Error loading user data:", error);
await fetchResults();
}
};
onMounted(() => { onMounted(() => {
fetchResults(); loadUserData();
}); });
</script> </script>
<template> <template>
<div class="mb-8"> <div class="mb-8">
<div class="card bg-base-100 shadow-lg"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="card-body"> <!-- Left Column: Your Ranking -->
<h2 class="card-title text-2xl mb-6"> <div class="lg:col-span-1">
<i class="mdi mdi-trophy text-yellow-500 mr-2"></i> <div class="card bg-base-100 shadow-lg sticky top-8">
General Results <div class="bg-gradient-to-br from-blue-600 to-blue-400 p-6 text-white rounded-t-2xl">
</h2> <i class="mdi mdi-trophy text-4xl"></i>
<h3 class="text-2xl font-bold mt-2">Your Ranking</h3>
</div>
<div class="card-body">
<div class="text-center mb-6">
<p class="text-sm text-base-content/70">Player</p>
<p class="text-3xl font-bold">{{ userInfo.username }}</p>
</div>
<div v-if="isLoading" class="flex justify-center py-8"> <div class="divider"></div>
<span class="loading loading-spinner loading-lg"></span>
<div v-if="isLoading" class="flex justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
<div v-else class="space-y-4">
<div class="text-center">
<p class="text-sm text-base-content/70 mb-1">Current Rank</p>
<p v-if="userInfo.rank !== null" class="text-4xl font-bold text-primary">
#{{ userInfo.rank }}
</p>
<p v-else class="text-2xl text-base-content/50">No rank yet</p>
</div>
<div class="text-center">
<p class="text-sm text-base-content/70 mb-1">Total Points</p>
<p class="text-2xl font-bold">{{ userInfo.totalPoints.toLocaleString() }}</p>
</div>
<div class="text-center">
<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> </div>
</div>
<!-- Right Column: Results -->
<div class="lg:col-span-2">
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="card-title text-2xl mb-6">
<i class="mdi mdi-trophy text-yellow-500 mr-2"></i>
General Results
</h2>
<div v-if="isLoading" class="flex justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
<div v-else-if="!resultsData" class="text-center py-8"> <div v-else-if="!resultsData" class="text-center py-8">
<p class="text-base-content/70">No results available yet</p> <p class="text-base-content/70">No results available yet</p>
@ -286,6 +390,8 @@ onMounted(() => {
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>

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"