Compare commits

..
14 Commits
39 changed files with 4430 additions and 644 deletions
+17
View File
@@ -3,9 +3,26 @@ from django.contrib import admin
from .models import Game
def enable_games(modeladmin, request, queryset):
updated = queryset.update(enabled=True)
modeladmin.message_user(request, f"{updated} game(s) enabled successfully.")
enable_games.short_description = "Enable selected games"
def disable_games(modeladmin, request, queryset):
updated = queryset.update(enabled=False)
modeladmin.message_user(request, f"{updated} game(s) disabled successfully.")
disable_games.short_description = "Disable selected games"
@admin.register(Game)
class GameAdmin(admin.ModelAdmin):
list_display = ["name", "steam_app_id", "enabled", "updated_at"]
list_filter = ["enabled"]
search_fields = ["name", "steam_app_id"]
readonly_fields = ["created_at", "updated_at"]
actions = [enable_games, disable_games]
@@ -0,0 +1,22 @@
# Generated by Django 6.0.5 on 2026-05-24 22:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0003_game_path"),
]
operations = [
migrations.AddField(
model_name="game",
name="image",
field=models.URLField(blank=True, null=True),
),
migrations.AlterField(
model_name="game",
name="steam_app_id",
field=models.PositiveIntegerField(blank=True, null=True),
),
]
+4 -1
View File
@@ -2,9 +2,10 @@ from django.db import models
class Game(models.Model):
steam_app_id = models.PositiveIntegerField(unique=True)
steam_app_id = models.PositiveIntegerField(null=True, blank=True)
name = models.CharField(max_length=255)
path = models.CharField(max_length=100)
image = models.URLField(null=True, blank=True)
enabled = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
@@ -14,4 +15,6 @@ class Game(models.Model):
ordering = ["name"]
def __str__(self) -> str:
if self.steam_app_id:
return f"{self.name} ({self.steam_app_id})"
return self.name
+5 -1
View File
@@ -1,7 +1,11 @@
from typing import Optional
from ninja import Schema
class GameOut(Schema):
steam_app_id: int
id: int
steam_app_id: Optional[int] = None
name: str
path: str
image: Optional[str] = None
+5 -3
View File
@@ -2,12 +2,13 @@ from ninja import NinjaAPI
from django.conf import settings
from django.core.cache import cache
from django.http import HttpRequest
from opus_magnum.api import router as submissions_router
from opus_magnum.api import router as opus_magnum_router
from opus_magnum.schemas import UserInfoOut
from animations.api import router as results_router
from noita.api import router as noita_router
from games.api import router as games_router
from market.api import router as market_router
from submissions.api import router as submissions_router
# Create the main API instance
api = NinjaAPI(
@@ -33,11 +34,12 @@ The Noita Submission API allows clients to upload the result of the log file of
# Add authentication for protected endpoints
# api.auth = django_auth # Uncomment if you want global auth
# Include the opus_magnum router
api.add_router("/opus-magnum/", submissions_router, tags=["opus-magnum"])
# Include the routers
api.add_router("/opus-magnum/", opus_magnum_router, tags=["opus-magnum"])
api.add_router("/results/", results_router, tags=["results"])
api.add_router("/noita/", noita_router, tags=["noita"])
api.add_router("/games/", games_router, tags=["games"])
api.add_router("/submissions/", submissions_router, tags=["submissions"])
if settings.MARKET_ENABLED:
api.add_router("/market/", market_router)
+12 -1
View File
@@ -10,7 +10,18 @@ https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polylan_submitter.settings")
application = get_asgi_application()
django_asgi_app = get_asgi_application()
from submissions.routing import websocket_urlpatterns
application = ProtocolTypeRouter(
{
"http": django_asgi_app,
"websocket": AuthMiddlewareStack(URLRouter(websocket_urlpatterns)),
}
)
@@ -16,6 +16,9 @@ import os
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
os.environ.setdefault("AWS_REQUEST_CHECKSUM_CALCULATION", "when_required")
os.environ.setdefault("AWS_RESPONSE_CHECKSUM_VALIDATION", "when_required")
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
@@ -32,6 +35,7 @@ ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
# Application definition
INSTALLED_APPS = [
"daphne",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
@@ -39,16 +43,20 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"django_vite",
"django_rq",
"django_tasks_rq",
"accounts",
"animations",
"opus_magnum",
"noita",
"games",
"market",
"submissions",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
@@ -75,7 +83,7 @@ TEMPLATES = [
},
]
WSGI_APPLICATION = "polylan_submitter.wsgi.application"
ASGI_APPLICATION = "polylan_submitter.asgi.application"
# Database
@@ -159,6 +167,33 @@ CACHES = {
}
}
# Django Channels Configuration
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [("127.0.0.1", 6379)],
"serializer_format": "cjson",
},
},
}
# Django Tasks Configuration (with RQ backend)
TASKS = {
"default": {
"BACKEND": "django_tasks_rq.RQBackend",
}
}
RQ_QUEUES = {
"default": {
"HOST": "127.0.0.1",
"PORT": 6379,
"DB": 12,
"DEFAULT_RESULT_TTL": 500,
}
}
# Allowed file types for submissions
ALLOWED_SUBMISSION_TYPES = [
"image/jpeg",
@@ -195,6 +230,16 @@ STATICFILES_DIRS = [
os.path.join(BASE_DIR, "static_source/vite"),
]
STORAGES = {
"default": {
"BACKEND": "storages.backends.s3.S3Storage",
},
"staticfiles": {
"BACKEND": "storages.backends.s3.S3Storage",
},
}
from polylan_submitter.settingsLocal import * # noqa
@@ -25,6 +25,7 @@ from django.conf.urls.static import static
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
from games.decorators import require_game_enabled
from market.views import market_home
from submissions.views import submission_home
from .api import api
NOITA_APP_ID = 881100
@@ -72,6 +73,7 @@ urlpatterns = [
path("market", market_home, name="market.home"),
path("opus-magnum", opus_magnum_home, name="opus-magnum.home"),
path("noita", noita_home, name="noita.home"),
path("submissions/<str:game>", submission_home, name="submissions.home"),
path("", home, name="home"),
]
+14 -8
View File
@@ -15,12 +15,18 @@ const games = ref<GamesApiListGamesResponse | undefined>();
const loading = ref(true);
const imageErrors = ref<Set<number>>(new Set());
const getHeaderImage = (appId: number) => {
return `https://cdn.akamai.steamstatic.com/steam/apps/${appId}/header.jpg`;
const getHeaderImage = (game: GamesApiListGamesResponse[number]) => {
if (game.image) {
return game.image;
}
if (game.steam_app_id) {
return `https://cdn.akamai.steamstatic.com/steam/apps/${game.steam_app_id}/header.jpg`;
}
return null;
};
const onImageError = (appId: number) => {
imageErrors.value.add(appId);
const onImageError = (gameId: number) => {
imageErrors.value.add(gameId);
};
const navigate = (path: string) => {
@@ -82,11 +88,11 @@ onMounted(async () => {
</div>
<!-- Game Cards -->
<div v-for="game in games" :key="game.steam_app_id" @click="navigate(game.path)"
<div v-for="game in games" :key="game.id" @click="navigate(game.path)"
class="card card-xl bg-base-200 shadow-xl hover:shadow-2xl transition-all cursor-pointer transform hover:-translate-y-2 hover:scale-[1.05] hover:bg-base-100 overflow-hidden">
<figure class="relative h-60 bg-base-300 overflow-hidden">
<img v-if="!imageErrors.has(game.steam_app_id)" :src="getHeaderImage(game.steam_app_id)" :alt="game.name"
@error="onImageError(game.steam_app_id)" class="w-full h-full object-cover" />
<img v-if="getHeaderImage(game) && !imageErrors.has(game.id)" :src="getHeaderImage(game)!" :alt="game.name"
@error="onImageError(game.id)" class="w-full h-full object-cover" />
<div v-else
class="w-full h-full bg-gradient-to-br from-blue-600 to-blue-400 flex items-center justify-center text-white">
<i class="mdi mdi-gamepad-variant text-5xl"></i>
@@ -96,7 +102,7 @@ onMounted(async () => {
<div class="card-body">
<h2 class="card-title text-2xl">{{ game.name }}</h2>
<div class="card-actions justify-end mt-4">
<button class="btn btn-primary">
<button @click="navigate(game.path)" class="btn btn-primary">
<i class="mdi mdi-arrow-right mr-2"></i>
Submit results
</button>
+1 -1
View File
@@ -175,7 +175,7 @@ onMounted(() => {
<h1 class="text-xl font-bold">Noita Submitter</h1>
<div class="flex-1"></div>
<a href="/api/docs" class="btn btn-xs">API docs</a>
<a href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
<a href="/admin" v-if="userInfo.isStaff" class="btn btn-xs btn-warning">Admin panel</a>
</div>
</div>
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,4 +1,4 @@
// This file is auto-generated by @hey-api/openapi-ts
export { animationsApiPuzzleResults, animationsApiResults, animationsApiTopSubmissions, gamesApiListGames, marketApiCloseMarket, marketApiCreateBet, marketApiListMarkets, marketApiListUserBets, marketApiResolveMarket, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck } from './sdk.gen';
export type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, AnimationsApiTopSubmissionsResponses, ClientOptions, GameOut, GamesApiListGamesData, GamesApiListGamesResponse, GamesApiListGamesResponses, Input, LeaderboardEntryOut, LeaderboardOut, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponse, MarketApiCreateBetResponses, MarketApiListMarketsData, MarketApiListMarketsResponse, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponse, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponse, MarketApiResolveMarketResponses, MarketListSchema, MarketOptionSchema, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponse, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponse, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileError, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponse, NoitaApiSubmitLogFileResponses, NoitaSubmissionOut, ObjectivResultOut, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponse, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponse, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponse, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponse, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponse, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponse, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponse, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponse, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponse, OpusMagnumApiValidateSubmissionResponses, PagedSubmissionOut, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponse, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, PuzzlePointsFactorOut, PuzzleResponseIn, PuzzleResponseOut, PuzzleResponseRankingOut, PuzzleResultsOut, PuzzleSubmissionsOut, PuzzleSubmissionWithRankOut, RankingSchema, ResolveMarketSchema, ResultsOut, SteamCollectionItemOut, SteamCollectionOut, SubmissionFileOut, SubmissionIn, SubmissionOut, TournamentPuzzleResultsOut, TournamentSubmissionsOut, UserBetCreateSchema, UserBetSchema, UserDisplayOut, UserInfoOut, ValidationIn, WinnerFileOut, WinnerResponseOut } from './types.gen';
export { animationsApiPuzzleResults, animationsApiResults, animationsApiTopSubmissions, gamesApiListGames, marketApiCloseMarket, marketApiCreateBet, marketApiListMarkets, marketApiListUserBets, marketApiResolveMarket, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck, submissionsApiCreateImageCrop, submissionsApiCreateSubmission, submissionsApiDeleteImageCrop, submissionsApiGetSubmission, submissionsApiListSubmissionsByGame, submissionsApiListUserSubmissions, submissionsApiProcessCropOcr, submissionsApiProcessFileOcr } from './sdk.gen';
export type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, AnimationsApiTopSubmissionsResponses, ClientOptions, GameOut, GamesApiListGamesData, GamesApiListGamesResponse, GamesApiListGamesResponses, ImageCropIn, ImageCropOut, Input, LeaderboardEntryOut, LeaderboardOut, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponse, MarketApiCreateBetResponses, MarketApiListMarketsData, MarketApiListMarketsResponse, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponse, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponse, MarketApiResolveMarketResponses, MarketListSchema, MarketOptionSchema, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponse, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponse, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileError, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponse, NoitaApiSubmitLogFileResponses, NoitaSubmissionOut, ObjectivResultOut, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponse, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponse, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponse, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponse, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponse, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponse, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponse, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponse, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponse, OpusMagnumApiValidateSubmissionResponses, PagedSubmissionOut, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponse, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, PuzzlePointsFactorOut, PuzzleResponseIn, PuzzleResponseOut, PuzzleResponseRankingOut, PuzzleResultsOut, PuzzleSubmissionsOut, PuzzleSubmissionWithRankOut, RankingSchema, ResolveMarketSchema, ResultsOut, SteamCollectionItemOut, SteamCollectionOut, SubmissionFileOut, SubmissionIn, SubmissionOut, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponse, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionResponse, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponse, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponse, SubmissionsApiGetSubmissionResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponse, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsResponse, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponse, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponse, SubmissionsApiProcessFileOcrResponses, TournamentPuzzleResultsOut, TournamentSubmissionsOut, UserBetCreateSchema, UserBetSchema, UserDisplayOut, UserInfoOut, ValidationIn, WinnerFileOut, WinnerResponseOut } from './types.gen';
+56 -1
View File
@@ -2,7 +2,7 @@
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
import { client } from './client.gen';
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses } from './types.gen';
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/**
@@ -191,6 +191,61 @@ export const noitaApiSubmitLogFile = <ThrowOnError extends boolean = false>(opti
*/
export const gamesApiListGames = <ThrowOnError extends boolean = false>(options?: Options<GamesApiListGamesData, ThrowOnError>) => (options?.client ?? client).get<GamesApiListGamesResponses, unknown, ThrowOnError>({ url: '/api/games/', ...options });
/**
* Create Submission
*/
export const submissionsApiCreateSubmission = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiCreateSubmissionData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiCreateSubmissionResponses, unknown, ThrowOnError>({
...formDataBodySerializer,
url: '/api/submissions/submission/create',
...options,
headers: {
'Content-Type': null,
...options.headers
}
});
/**
* List User Submissions
*/
export const submissionsApiListUserSubmissions = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiListUserSubmissionsData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiListUserSubmissionsResponses, unknown, ThrowOnError>({ url: '/api/submissions/submission/by/user', ...options });
/**
* List Submissions By Game
*/
export const submissionsApiListSubmissionsByGame = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiListSubmissionsByGameData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiListSubmissionsByGameResponses, unknown, ThrowOnError>({ url: '/api/submissions/submission/by/game', ...options });
/**
* Get Submission
*/
export const submissionsApiGetSubmission = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiGetSubmissionData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiGetSubmissionResponses, unknown, ThrowOnError>({ url: '/api/submissions/submission/{submission_id}', ...options });
/**
* Create Image Crop
*/
export const submissionsApiCreateImageCrop = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiCreateImageCropData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiCreateImageCropResponses, unknown, ThrowOnError>({
url: '/api/submissions/crops/s/{submission_file_id}',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Delete Image Crop
*/
export const submissionsApiDeleteImageCrop = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiDeleteImageCropData, ThrowOnError>) => (options.client ?? client).delete<SubmissionsApiDeleteImageCropResponses, unknown, ThrowOnError>({ url: '/api/submissions/crops/{crop_id}', ...options });
/**
* Process File Ocr
*/
export const submissionsApiProcessFileOcr = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiProcessFileOcrData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiProcessFileOcrResponses, unknown, ThrowOnError>({ url: '/api/submissions/files/{file_id}/process-ocr', ...options });
/**
* Process Crop Ocr
*/
export const submissionsApiProcessCropOcr = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiProcessCropOcrData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiProcessCropOcrResponses, unknown, ThrowOnError>({ url: '/api/submissions/crops/{crop_id}/process-ocr', ...options });
/**
* List Markets
*
+293 -79
View File
@@ -341,120 +341,62 @@ export type PuzzleResponseOut = {
/**
* SubmissionFileOut
*
* Schema for submission file output
*/
export type SubmissionFileOut = {
/**
* File Url
* Id
*/
file_url: string | null;
/**
* ID
*/
id: number;
id: string;
/**
* Original Filename
*
* Original filename as uploaded by user
*/
original_filename: string;
/**
* File Size
*
* File size in bytes
*/
file_size: number;
/**
* Content Type
*
* MIME type of the file
*/
content_type: string;
/**
* Ocr Processed
*
* Whether OCR has been processed for this file
* File Size
*/
ocr_processed?: boolean;
file_size: number;
/**
* File Url
*/
file_url?: string | null;
/**
* Ocr Status
*/
ocr_status: string;
/**
* Ocr Raw Data
*
* Raw OCR data as JSON
*/
ocr_raw_data?: {
[key: string]: unknown;
} | null;
ocr_raw_data: string;
/**
* Ocr Error
*
* OCR processing error message
* Crops
*/
ocr_error: string;
/**
* Created At
*/
created_at: string;
crops?: Array<ImageCropOut>;
};
/**
* SubmissionOut
*
* Schema for submission output
*/
export type SubmissionOut = {
/**
* Responses
*/
responses: Array<PuzzleResponseOut>;
/**
* Total Responses
*/
total_responses: number;
/**
* Needs Validation
*/
needs_validation: boolean;
/**
* Id
*/
id?: string;
id: string;
/**
* User
*
* User who made the submission (null for anonymous)
* Game Id
*/
user_id?: number | null;
game_id?: number | null;
/**
* Notes
*
* Optional notes about the submission
*/
notes?: string | null;
/**
* Is Validated
*
* Whether this submission has been manually validated
* Files
*/
is_validated?: boolean;
/**
* Validated By
*
* Admin user who validated this submission
*/
validated_by_id?: number | null;
/**
* Validated At
*
* When this submission was validated
*/
validated_at?: string | null;
/**
* Manual Validation Requested
*
* Whether the user specifically requested manual validation
*/
manual_validation_requested?: boolean;
files?: Array<SubmissionFileOut>;
/**
* Created At
*/
@@ -952,10 +894,14 @@ export type NoitaSubmissionOut = {
* GameOut
*/
export type GameOut = {
/**
* Id
*/
id: number;
/**
* Steam App Id
*/
steam_app_id: number;
steam_app_id?: number | null;
/**
* Name
*/
@@ -964,6 +910,78 @@ export type GameOut = {
* Path
*/
path: string;
/**
* Image
*/
image?: string | null;
};
/**
* ImageCropOut
*/
export type ImageCropOut = {
/**
* Id
*/
id: string;
/**
* X1
*/
x1: number;
/**
* Y1
*/
y1: number;
/**
* X2
*/
x2: number;
/**
* Y2
*/
y2: number;
/**
* Label
*/
label: string;
/**
* Ocr Status
*/
ocr_status: string;
/**
* Ocr Raw Data
*/
ocr_raw_data: string;
/**
* Created At
*/
created_at: string;
};
/**
* ImageCropIn
*/
export type ImageCropIn = {
/**
* X1
*/
x1: number;
/**
* Y1
*/
y1: number;
/**
* X2
*/
x2: number;
/**
* Y2
*/
y2: number;
/**
* Label
*/
label?: string | null;
};
/**
@@ -1475,6 +1493,202 @@ export type GamesApiListGamesResponses = {
export type GamesApiListGamesResponse = GamesApiListGamesResponses[keyof GamesApiListGamesResponses];
export type SubmissionsApiCreateSubmissionData = {
/**
* MultiPartBodyParams
*/
body: {
/**
* Files
*/
files: Array<Blob | File>;
/**
* Notes
*/
notes?: string | null;
};
path?: never;
query: {
/**
* Game Path
*/
game_path: string;
};
url: '/api/submissions/submission/create';
};
export type SubmissionsApiCreateSubmissionResponses = {
/**
* OK
*/
200: SubmissionOut;
};
export type SubmissionsApiCreateSubmissionResponse = SubmissionsApiCreateSubmissionResponses[keyof SubmissionsApiCreateSubmissionResponses];
export type SubmissionsApiListUserSubmissionsData = {
body?: never;
path?: never;
query: {
/**
* Game Path
*/
game_path: string;
};
url: '/api/submissions/submission/by/user';
};
export type SubmissionsApiListUserSubmissionsResponses = {
/**
* Response
*
* OK
*/
200: Array<SubmissionOut>;
};
export type SubmissionsApiListUserSubmissionsResponse = SubmissionsApiListUserSubmissionsResponses[keyof SubmissionsApiListUserSubmissionsResponses];
export type SubmissionsApiListSubmissionsByGameData = {
body?: never;
path?: never;
query: {
/**
* Game Path
*/
game_path: string;
};
url: '/api/submissions/submission/by/game';
};
export type SubmissionsApiListSubmissionsByGameResponses = {
/**
* Response
*
* OK
*/
200: Array<SubmissionOut>;
};
export type SubmissionsApiListSubmissionsByGameResponse = SubmissionsApiListSubmissionsByGameResponses[keyof SubmissionsApiListSubmissionsByGameResponses];
export type SubmissionsApiGetSubmissionData = {
body?: never;
path: {
/**
* Submission Id
*/
submission_id: string;
};
query?: never;
url: '/api/submissions/submission/{submission_id}';
};
export type SubmissionsApiGetSubmissionResponses = {
/**
* OK
*/
200: SubmissionOut;
};
export type SubmissionsApiGetSubmissionResponse = SubmissionsApiGetSubmissionResponses[keyof SubmissionsApiGetSubmissionResponses];
export type SubmissionsApiCreateImageCropData = {
body: ImageCropIn;
path: {
/**
* Submission File Id
*/
submission_file_id: string;
};
query?: never;
url: '/api/submissions/crops/s/{submission_file_id}';
};
export type SubmissionsApiCreateImageCropResponses = {
/**
* OK
*/
200: ImageCropOut;
};
export type SubmissionsApiCreateImageCropResponse = SubmissionsApiCreateImageCropResponses[keyof SubmissionsApiCreateImageCropResponses];
export type SubmissionsApiDeleteImageCropData = {
body?: never;
path: {
/**
* Crop Id
*/
crop_id: string;
};
query?: never;
url: '/api/submissions/crops/{crop_id}';
};
export type SubmissionsApiDeleteImageCropResponses = {
/**
* Response
*
* OK
*/
200: {
[key: string]: unknown;
};
};
export type SubmissionsApiDeleteImageCropResponse = SubmissionsApiDeleteImageCropResponses[keyof SubmissionsApiDeleteImageCropResponses];
export type SubmissionsApiProcessFileOcrData = {
body?: never;
path: {
/**
* File Id
*/
file_id: string;
};
query?: never;
url: '/api/submissions/files/{file_id}/process-ocr';
};
export type SubmissionsApiProcessFileOcrResponses = {
/**
* Response
*
* OK
*/
200: {
[key: string]: unknown;
};
};
export type SubmissionsApiProcessFileOcrResponse = SubmissionsApiProcessFileOcrResponses[keyof SubmissionsApiProcessFileOcrResponses];
export type SubmissionsApiProcessCropOcrData = {
body?: never;
path: {
/**
* Crop Id
*/
crop_id: string;
};
query?: never;
url: '/api/submissions/crops/{crop_id}/process-ocr';
};
export type SubmissionsApiProcessCropOcrResponses = {
/**
* Response
*
* OK
*/
200: {
[key: string]: unknown;
};
};
export type SubmissionsApiProcessCropOcrResponse = SubmissionsApiProcessCropOcrResponses[keyof SubmissionsApiProcessCropOcrResponses];
export type MarketApiListMarketsData = {
body?: never;
path?: never;
@@ -0,0 +1,156 @@
import { ref, computed, onBeforeUnmount } from 'vue'
import { useWebSocket } from '@vueuse/core'
interface SubmissionUpdate {
id: string
game_id?: number
notes?: string
files?: SubmissionFileUpdate[]
created_at: string
updated_at: string
}
interface SubmissionFileUpdate {
id: string
original_filename: string
content_type: string
file_size: number
file_url?: string
ocr_status: string
ocr_raw_data: string
crops?: ImageCropUpdate[]
}
interface ImageCropUpdate {
id: string
x1: number
y1: number
x2: number
y2: number
label: string
ocr_status: string
ocr_raw_data: string
created_at: string
}
type WebSocketData = SubmissionUpdate | SubmissionFileUpdate | ImageCropUpdate
export function useSubmissionsWebSocket(userSubmissions: any) {
const isConnected = ref(false)
const reconnectAttempts = ref(0)
const maxReconnectAttempts = 10
const reconnectDelay = ref(3000)
const wsUrl = computed(() => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${protocol}//${window.location.host}/ws/submissions/`
})
const {
status,
open,
close,
} = useWebSocket(wsUrl.value, {
onConnected: () => {
isConnected.value = true
reconnectAttempts.value = 0
reconnectDelay.value = 3000
console.log('✓ Connected to submissions WebSocket')
},
onDisconnected: () => {
isConnected.value = false
console.log('✗ Disconnected from submissions WebSocket, attempting to reconnect...')
attemptReconnect()
},
onError: (event) => {
console.error('WebSocket error:', event)
},
onMessage: (_ws, event) => {
try {
const messageData = event.data as string
const data = JSON.parse(messageData) as WebSocketData
if ('files' in data) {
handleSubmissionUpdate(data as SubmissionUpdate)
} else if ('original_filename' in data) {
handleFileUpdate(data as SubmissionFileUpdate)
} else if ('x1' in data) {
handleCropUpdate(data as ImageCropUpdate)
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error)
}
},
immediate: true,
})
const attemptReconnect = () => {
if (reconnectAttempts.value >= maxReconnectAttempts) {
console.error('Max reconnection attempts reached')
return
}
reconnectAttempts.value++
const delay = reconnectDelay.value * Math.pow(1.5, reconnectAttempts.value - 1)
console.log(`Reconnecting (attempt ${reconnectAttempts.value}/${maxReconnectAttempts}) in ${Math.round(delay / 1000)}s...`)
setTimeout(() => {
open()
}, delay)
}
const handleSubmissionUpdate = (submission: SubmissionUpdate) => {
const existingIndex = userSubmissions.value.findIndex((s: any) => s.id === submission.id)
if (existingIndex >= 0) {
// Merge into existing object to preserve references
Object.assign(userSubmissions.value[existingIndex], submission)
} else {
userSubmissions.value.unshift(submission)
}
console.log('Updated submission:', submission.id)
}
const handleFileUpdate = (file: SubmissionFileUpdate) => {
userSubmissions.value.forEach((submission: any) => {
if (!submission.files) return
const fileIndex = submission.files.findIndex((f: any) => f.id === file.id)
if (fileIndex >= 0) {
// Merge into existing object to preserve references
Object.assign(submission.files[fileIndex], file)
console.log('Updated file:', file.id)
}
})
}
const handleCropUpdate = (crop: ImageCropUpdate) => {
userSubmissions.value.forEach((submission: any) => {
if (!submission.files) return
submission.files.forEach((file: any) => {
if (!file.crops) return
const cropIndex = file.crops.findIndex((c: any) => c.id === crop.id)
if (cropIndex >= 0) {
// Merge into existing object to preserve references
Object.assign(file.crops[cropIndex], crop)
console.log('Updated crop:', crop.id)
}
})
})
}
onBeforeUnmount(() => {
close()
})
return {
isConnected,
status,
open,
close,
}
}
+43
View File
@@ -0,0 +1,43 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
interface UserInfo {
username: string
isAuthenticated: boolean
isStaff: boolean
}
export const useUserStore = defineStore('user', () => {
const userInfo = ref<UserInfo>({
username: '',
isAuthenticated: false,
isStaff: false,
})
const isLoading = ref(false)
const error = ref<string>('')
const loadUserData = async () => {
isLoading.value = true
try {
const response = await fetch('/api/user')
if (response.ok) {
const user = await response.json()
userInfo.value.isAuthenticated = user.is_authenticated || false
userInfo.value.username = user.username || ''
userInfo.value.isStaff = user.is_staff || false
}
} catch (err) {
error.value = 'Failed to fetch user info'
console.error('Error fetching user info:', err)
} finally {
isLoading.value = false
}
}
return {
userInfo,
isLoading,
error,
loadUserData,
}
})
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import Submissions from '@/Submissions.vue'
import { pinia } from '@/stores'
import '@/style.css'
const selector = "#app"
const mountData = document.querySelector<HTMLElement>(selector)
const app = createApp(Submissions, { ...mountData?.dataset })
app.use(pinia)
app.mount(selector)
+106
View File
@@ -0,0 +1,106 @@
from django.contrib import admin
from .models import Submission, SubmissionFile, ImageCrop
class ImageCropInline(admin.TabularInline):
model = ImageCrop
extra = 1
fields = [
"label",
"x1",
"y1",
"x2",
"y2",
"ocr_status",
"created_at",
]
readonly_fields = ["created_at"]
class SubmissionFileInline(admin.TabularInline):
model = SubmissionFile
extra = 0
fields = [
"original_filename",
"file_size",
"content_type",
"ocr_status",
"created_at",
]
readonly_fields = [
"file_size",
"content_type",
"created_at",
]
def get_queryset(self, request):
return super().get_queryset(request)
@admin.register(Submission)
class SubmissionAdmin(admin.ModelAdmin):
list_display = ["id_short", "game", "user", "notes_preview", "created_at"]
list_filter = ["game", "created_at", "updated_at"]
search_fields = ["id", "user__email", "notes", "game__name"]
readonly_fields = ["id", "created_at", "updated_at"]
inlines = [SubmissionFileInline]
def get_queryset(self, request):
return super().get_queryset(request).select_related("user", "game")
@admin.display(description="ID")
def id_short(self, obj):
return str(obj.id)[:8]
@admin.display(description="Notes")
def notes_preview(self, obj):
if obj.notes:
return obj.notes[:50] + "..." if len(obj.notes) > 50 else obj.notes
return "-"
@admin.register(SubmissionFile)
class SubmissionFileAdmin(admin.ModelAdmin):
list_display = [
"original_filename",
"submission_id",
"file_size",
"content_type",
"ocr_status",
"created_at",
]
list_filter = ["content_type", "ocr_status", "created_at"]
search_fields = ["original_filename", "submission__id"]
readonly_fields = ["file_size", "content_type", "created_at", "updated_at"]
inlines = [ImageCropInline]
def get_queryset(self, request):
return super().get_queryset(request).select_related("submission")
@admin.display(description="Submission ID")
def submission_id(self, obj):
return str(obj.submission.id)[:8]
@admin.register(ImageCrop)
class ImageCropAdmin(admin.ModelAdmin):
list_display = [
"label_or_coords",
"file_name",
"ocr_status",
"created_at",
]
list_filter = ["ocr_status", "created_at"]
search_fields = ["label", "submission_file__original_filename"]
readonly_fields = ["created_at", "updated_at"]
def get_queryset(self, request):
return super().get_queryset(request).select_related("submission_file")
@admin.display(description="Crop")
def label_or_coords(self, obj):
return obj.label or f"({obj.x1}, {obj.y1}, {obj.x2}, {obj.y2})"
@admin.display(description="File")
def file_name(self, obj):
return obj.submission_file.original_filename
+137
View File
@@ -0,0 +1,137 @@
from typing import List, Optional
from uuid import UUID
from django.http import HttpRequest
from ninja import Router, File, Form
from ninja.files import UploadedFile
from django.shortcuts import get_object_or_404
from games.models import Game
from .models import Submission, SubmissionFile, ImageCrop, OCRStatus
from .schemas import SubmissionOut, ImageCropIn, ImageCropOut
from .tasks import process_submission_file, process_crop
router = Router()
@router.post("/submission/create", response=SubmissionOut)
def create_submission(
request: HttpRequest,
game_path: str,
files: List[UploadedFile] = File(...),
notes: Optional[str] = Form(None),
):
game = get_object_or_404(Game, path=game_path, enabled=True)
submission = Submission.objects.create(
game=game,
user=request.user if request.user.is_authenticated else None,
notes=notes or None,
)
for file in files:
SubmissionFile.objects.create(
submission=submission,
file=file,
original_filename=file.name,
content_type=file.content_type or "application/octet-stream",
)
return submission
@router.get("/submission/by/user", response=list[SubmissionOut])
def list_user_submissions(request: HttpRequest, game_path: str):
game = get_object_or_404(Game, path=game_path)
if not request.user.is_authenticated:
return 403, {"detail": "Authentication required"}
submissions = Submission.objects.filter(game=game, user=request.user).order_by(
"-created_at"
)
return submissions
@router.get("/submission/by/game", response=list[SubmissionOut])
def list_submissions_by_game(request: HttpRequest, game_path: str):
game = get_object_or_404(Game, path=game_path)
if not request.user.is_staff:
return 403, {"detail": "Admin access required"}
return Submission.objects.filter(game=game)
@router.get("/submission/{submission_id}", response=SubmissionOut)
def get_submission(request: HttpRequest, submission_id: UUID):
submission = get_object_or_404(Submission, id=submission_id)
if submission.user != request.user and not request.user.is_staff:
return 403, {"detail": "Not authorized"}
return submission
@router.post("/crops/s/{submission_file_id}", response=ImageCropOut)
def create_image_crop(
request: HttpRequest,
submission_file_id: UUID,
data: ImageCropIn,
):
if not request.user.is_staff:
return 403, {"detail": "Admin access required"}
submission_file = get_object_or_404(SubmissionFile, id=submission_file_id)
crop = ImageCrop.objects.create(
submission_file=submission_file,
x1=data.x1,
y1=data.y1,
x2=data.x2,
y2=data.y2,
label=data.label or "",
ocr_status=OCRStatus.TO_PROCESS,
)
return crop
@router.delete("/crops/{crop_id}", response=dict)
def delete_image_crop(request: HttpRequest, crop_id: UUID):
if not request.user.is_staff:
return 403, {"detail": "Admin access required"}
crop = get_object_or_404(ImageCrop, id=crop_id)
crop.delete()
return {"status": "deleted"}
@router.post("/files/{file_id}/process-ocr", response=dict)
def process_file_ocr(request: HttpRequest, file_id: UUID):
if not request.user.is_staff:
return 403, {"detail": "Admin access required"}
submission_file = get_object_or_404(SubmissionFile, id=file_id)
submission_file.ocr_status = OCRStatus.TO_PROCESS
submission_file.save()
# Enqueue the OCR processing task
process_submission_file.enqueue(str(file_id))
return {"status": "queued"}
@router.post("/crops/{crop_id}/process-ocr", response=dict)
def process_crop_ocr(request: HttpRequest, crop_id: UUID):
if not request.user.is_staff:
return 403, {"detail": "Admin access required"}
crop = get_object_or_404(ImageCrop, id=crop_id)
crop.ocr_status = OCRStatus.TO_PROCESS
crop.save()
# Enqueue the OCR processing task
process_crop.enqueue(str(crop_id))
return {"status": "queued"}
+24
View File
@@ -0,0 +1,24 @@
from django.apps import AppConfig
from django.core.serializers.json import DjangoJSONEncoder
from channels_redis.serializers import JSONSerializer, registry
import json
class CJSONSerializer(JSONSerializer):
"""Override `JSONSerializer` to use `DjangoJSONEncoder`."""
def as_bytes(self, message, *args, **kwargs): # noqa: ANN001 ANN002 ANN003 ANN201
message = json.dumps(message, *args, **kwargs, cls=DjangoJSONEncoder)
return message.encode("utf-8")
class SubmissionsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "submissions"
def ready(self):
registry.register_serializer("cjson", CJSONSerializer)
import submissions.signals # noqa
@@ -0,0 +1,31 @@
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from django.contrib.auth.models import AnonymousUser
class SubmissionsConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.user = self.scope["user"]
if isinstance(self.user, AnonymousUser):
await self.close()
return
self.user_group_name = f"submissions_user_{self.user.id}"
await self.channel_layer.group_add(self.user_group_name, self.channel_name)
await self.accept()
async def disconnect(self, close_code):
await self.channel_layer.group_discard(self.user_group_name, self.channel_name)
async def submission_updated(self, event):
submission_data = event["submission"]
await self.send(text_data=json.dumps(submission_data))
async def submission_file_updated(self, event):
file_data = event["file"]
await self.send(text_data=json.dumps(file_data))
async def submission_crop_updated(self, event):
crop_data = event["crop"]
await self.send(text_data=json.dumps(crop_data))
@@ -0,0 +1,194 @@
# Generated by Django 6.0.5 on 2026-05-24 22:37
import django.db.models.deletion
import submissions.models
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Submission",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"notes",
models.TextField(
blank=True,
help_text="Optional notes about the submission",
null=True,
),
),
(
"user",
models.ForeignKey(
blank=True,
help_text="User who made the submission (null for anonymous)",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="submissions",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="SubmissionFile",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"file",
models.FileField(
help_text="Uploaded file",
upload_to=submissions.models.submission_file_upload_path,
),
),
(
"original_filename",
models.CharField(
help_text="Original filename as uploaded by user",
max_length=255,
),
),
(
"file_size",
models.PositiveIntegerField(help_text="File size in bytes"),
),
(
"content_type",
models.CharField(help_text="MIME type of the file", max_length=100),
),
(
"ocr_status",
models.CharField(
choices=[
("to_process", "To Process"),
("processing", "Processing"),
("processed", "Processed"),
("error", "Error processing"),
],
default="to_process",
help_text="OCR processing status for the entire file",
max_length=20,
),
),
(
"ocr_raw_data",
models.TextField(
blank=True, help_text="Raw OCR data for the entire file"
),
),
(
"submission",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="files",
to="submissions.submission",
),
),
],
options={
"verbose_name": "Submission File",
"verbose_name_plural": "Submission Files",
"ordering": ["file", "created_at"],
},
),
migrations.CreateModel(
name="ImageCrop",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("x1", models.PositiveIntegerField(help_text="Top-left x coordinate")),
("y1", models.PositiveIntegerField(help_text="Top-left y coordinate")),
(
"x2",
models.PositiveIntegerField(help_text="Bottom-right x coordinate"),
),
(
"y2",
models.PositiveIntegerField(help_text="Bottom-right y coordinate"),
),
(
"label",
models.CharField(
blank=True,
help_text="Optional label for this crop region",
max_length=255,
),
),
(
"ocr_status",
models.CharField(
choices=[
("to_process", "To Process"),
("processing", "Processing"),
("processed", "Processed"),
("error", "Error processing"),
],
default="to_process",
help_text="OCR processing status for this crop",
max_length=20,
),
),
(
"ocr_raw_data",
models.TextField(
blank=True, help_text="Raw OCR data for this crop"
),
),
(
"submission_file",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="crops",
to="submissions.submissionfile",
),
),
],
options={
"verbose_name": "Image Crop",
"verbose_name_plural": "Image Crops",
"ordering": ["created_at"],
},
),
]
@@ -0,0 +1,39 @@
# Generated by Django 6.0.5 on 2026-05-24 22:57
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0004_game_image_alter_game_steam_app_id"),
("submissions", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="submission",
name="game",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="submissions",
to="games.game",
),
),
migrations.AlterField(
model_name="submission",
name="user",
field=models.ForeignKey(
blank=True,
help_text="User who made the submission (null for anonymous)",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="user_submissions",
to=settings.AUTH_USER_MODEL,
),
),
]
+153
View File
@@ -0,0 +1,153 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
import uuid
from accounts.models import CustomUser
from games.models import Game
class OCRStatus(models.TextChoices):
TO_PROCESS = "to_process", _("To Process")
PROCESSING = "processing", _("Processing")
PROCESSED = "processed", _("Processed")
ERROR = "error", _("Error processing")
def submission_file_upload_path(instance, filename):
ext = filename.split(".")[-1] if "." in filename else ""
new_filename = f"{instance.id}_{filename}" if ext else str(instance.id)
date = instance.created_at.strftime("%Y/%m/%d")
user = instance.submission.user.username
return f"{instance.submission.game.path.lstrip('/')}/{date}/{user}/{instance.submission.id}/{new_filename}"
class BaseModel(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
class Submission(BaseModel):
game = models.ForeignKey(
Game,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="submissions",
)
user = models.ForeignKey(
CustomUser,
on_delete=models.CASCADE,
null=True,
blank=True,
help_text="User who made the submission (null for anonymous)",
related_name="user_submissions",
)
# Submission metadata
notes = models.TextField(
null=True,
blank=True,
help_text="Optional notes about the submission",
)
class SubmissionFile(BaseModel):
submission = models.ForeignKey(
Submission,
on_delete=models.CASCADE,
related_name="files",
)
file = models.FileField(
upload_to=submission_file_upload_path,
help_text=_("Uploaded file"),
)
original_filename = models.CharField(
max_length=255,
help_text=_("Original filename as uploaded by user"),
)
file_size = models.PositiveIntegerField(help_text=_("File size in bytes"))
content_type = models.CharField(
max_length=100,
help_text=_("MIME type of the file"),
)
# OCR metadata
ocr_status = models.CharField(
max_length=20,
choices=OCRStatus.choices,
default=OCRStatus.TO_PROCESS,
help_text=_("OCR processing status for the entire file"),
)
ocr_raw_data = models.TextField(
blank=True,
help_text=_("Raw OCR data for the entire file"),
)
class Meta:
ordering = ["file", "created_at"]
verbose_name = "Submission File"
verbose_name_plural = "Submission Files"
@property
def file_url(self) -> str | None:
if self.file:
return self.file.url
def save(self, *args, **kwargs):
if self.file and not self.file_size:
self.file_size = self.file.size
super().save(*args, **kwargs)
class ImageCrop(BaseModel):
submission_file = models.ForeignKey(
SubmissionFile,
on_delete=models.CASCADE,
related_name="crops",
)
# Crop coordinates (pixels): x1, y1, x2, y2
x1 = models.PositiveIntegerField(help_text=_("Top-left x coordinate"))
y1 = models.PositiveIntegerField(help_text=_("Top-left y coordinate"))
x2 = models.PositiveIntegerField(help_text=_("Bottom-right x coordinate"))
y2 = models.PositiveIntegerField(help_text=_("Bottom-right y coordinate"))
# Label/name for the crop
label = models.CharField(
max_length=255,
blank=True,
help_text=_("Optional label for this crop region"),
)
# OCR results for this crop
ocr_status = models.CharField(
max_length=20,
choices=OCRStatus.choices,
default=OCRStatus.TO_PROCESS,
help_text=_("OCR processing status for this crop"),
)
ocr_raw_data = models.TextField(
blank=True,
help_text=_("Raw OCR data for this crop"),
)
class Meta:
ordering = ["created_at"]
verbose_name = "Image Crop"
verbose_name_plural = "Image Crops"
def __str__(self):
return f"{self.label or f'Crop ({self.x1}, {self.y1}, {self.x2}, {self.y2})'}"
+6
View File
@@ -0,0 +1,6 @@
from django.urls import re_path
from submissions.consumers import SubmissionsConsumer
websocket_urlpatterns = [
re_path(r"ws/submissions/$", SubmissionsConsumer.as_asgi()),
]
+43
View File
@@ -0,0 +1,43 @@
from datetime import datetime
from uuid import UUID
from ninja import Schema
class ImageCropIn(Schema):
x1: int
y1: int
x2: int
y2: int
label: str | None = None
class ImageCropOut(Schema):
id: UUID
x1: int
y1: int
x2: int
y2: int
label: str
ocr_status: str
ocr_raw_data: str
created_at: datetime
class SubmissionFileOut(Schema):
id: UUID
original_filename: str
content_type: str
file_size: int
file_url: str | None = None
ocr_status: str
ocr_raw_data: str
crops: list[ImageCropOut] = []
class SubmissionOut(Schema):
id: UUID
game_id: int | None = None
notes: str | None = None
files: list[SubmissionFileOut] = []
created_at: datetime
updated_at: datetime
+63
View File
@@ -0,0 +1,63 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from submissions.models import Submission, SubmissionFile, ImageCrop
from submissions.schemas import SubmissionOut, SubmissionFileOut, ImageCropOut
channel_layer = get_channel_layer()
@receiver(post_save, sender=Submission)
def broadcast_submission_update(sender, instance, created, **kwargs):
if not instance.user:
return
user_group_name = f"submissions_user_{instance.user.id}"
submission_data = SubmissionOut.from_orm(instance).dict()
async_to_sync(channel_layer.group_send)(
user_group_name,
{
"type": "submission.updated",
"submission": submission_data,
},
)
@receiver(post_save, sender=SubmissionFile)
def broadcast_submission_file_update(sender, instance, created, **kwargs):
submission = instance.submission
if not submission.user:
return
user_group_name = f"submissions_user_{submission.user.id}"
file_data = SubmissionFileOut.from_orm(instance).dict()
async_to_sync(channel_layer.group_send)(
user_group_name,
{
"type": "submission.file.updated",
"file": file_data,
},
)
@receiver(post_save, sender=ImageCrop)
def broadcast_image_crop_update(sender, instance, created, **kwargs):
submission = instance.submission_file.submission
if not submission.user:
return
user_group_name = f"submissions_user_{submission.user.id}"
crop_data = ImageCropOut.from_orm(instance).dict()
async_to_sync(channel_layer.group_send)(
user_group_name,
{
"type": "submission.crop.updated",
"crop": crop_data,
},
)
+71
View File
@@ -0,0 +1,71 @@
from django.tasks import task
from submissions.models import ImageCrop, SubmissionFile, OCRStatus
from PIL import Image
import pytesseract
def _process_ocr_file(obj, file, crop: ImageCrop | None = None) -> bool:
if obj.ocr_status != OCRStatus.TO_PROCESS:
return False
obj.ocr_status = OCRStatus.PROCESSING
obj.save(update_fields=["ocr_status"])
try:
image = Image.open(file)
if crop:
image = image.crop((crop.x1, crop.y1, crop.x2, crop.y2))
obj.ocr_raw_data = pytesseract.image_to_string(image)
obj.ocr_status = OCRStatus.PROCESSED
except Exception as e:
obj.ocr_status = OCRStatus.ERROR
raise e
finally:
obj.save(update_fields=["ocr_status", "ocr_raw_data"])
return True
@task
def process_submission_files():
"""Process uploaded submission files (OCR, validation, etc.)"""
for file in SubmissionFile.objects.filter(ocr_status=OCRStatus.TO_PROCESS):
process_submission_file.enqueue(str(file.id))
@task
def process_submission_file(file_id: str):
file = SubmissionFile.objects.filter(id=file_id).first()
if not file:
return
_process_ocr_file(file, file.file)
process_crops.enqueue(file_id)
@task
def process_crops(file_id: str):
file = SubmissionFile.objects.filter(id=file_id).first()
if not file:
return
for crop in file.crops.all():
process_crop.enqueue(str(crop.id))
@task
def process_crop(crop_id: str):
crop = ImageCrop.objects.filter(id=crop_id).first()
if not crop:
return
_process_ocr_file(crop, crop.submission_file.file, crop=crop)
+1
View File
@@ -0,0 +1 @@
# Create your tests here.
+17
View File
@@ -0,0 +1,17 @@
from django.http import HttpRequest
from django.shortcuts import render, get_object_or_404
from django.contrib.auth.decorators import login_required
from games.models import Game
@login_required
def submission_home(request: HttpRequest, game: str):
game_obj = get_object_or_404(Game, path=f"/submissions/{game}", enabled=True)
return render(
request,
"submissions.html",
{
"game": game_obj,
},
)
@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% load django_vite %}
{% block title %}{{ game.name }} Submissions{% endblock %}
{% block head %}
{% vite_asset 'src/submissions.ts' %}
{% endblock %}
{% block content %}
<div id="app" data-game-path="{{ game.path }}" data-game-name="{{ game.name }}" data-game-image="{{ game.image |default:'' }}" data-game-steam-id="{{ game.steam_app_id|default:'' }}"></div>
{% endblock %}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/home.ts","./src/market.ts","./src/noita.ts","./src/opus-magnum.ts","./src/api/client.gen.ts","./src/api/index.ts","./src/api/sdk.gen.ts","./src/api/types.gen.ts","./src/api/client/client.gen.ts","./src/api/client/index.ts","./src/api/client/types.gen.ts","./src/api/client/utils.gen.ts","./src/api/core/auth.gen.ts","./src/api/core/bodySerializer.gen.ts","./src/api/core/params.gen.ts","./src/api/core/pathSerializer.gen.ts","./src/api/core/queryKeySerializer.gen.ts","./src/api/core/serverSentEvents.gen.ts","./src/api/core/types.gen.ts","./src/api/core/utils.gen.ts","./src/services/apiService.ts","./src/services/ocrService.ts","./src/stores/index.ts","./src/stores/market.ts","./src/stores/noita.ts","./src/stores/puzzles.ts","./src/stores/submissions.ts","./src/stores/uploads.ts","./src/types/index.ts","./src/Home.vue","./src/Market.vue","./src/Noita.vue","./src/OpusMagnum.vue","./src/components/AdminPanel.vue","./src/components/FileUpload.vue","./src/components/MarketCard.vue","./src/components/PuzzleCard.vue","./src/components/PuzzleResults.vue","./src/components/RankBadge.vue","./src/components/Results.vue","./src/components/SubmissionForm.vue","./src/components/TopUsersLeaderboard.vue","./src/components/UserBets.vue","./src/components/Winners.vue"],"version":"5.9.3"}
{"root":["./src/home.ts","./src/market.ts","./src/noita.ts","./src/opus-magnum.ts","./src/submissions.ts","./src/api/client.gen.ts","./src/api/index.ts","./src/api/sdk.gen.ts","./src/api/types.gen.ts","./src/api/client/client.gen.ts","./src/api/client/index.ts","./src/api/client/types.gen.ts","./src/api/client/utils.gen.ts","./src/api/core/auth.gen.ts","./src/api/core/bodySerializer.gen.ts","./src/api/core/params.gen.ts","./src/api/core/pathSerializer.gen.ts","./src/api/core/queryKeySerializer.gen.ts","./src/api/core/serverSentEvents.gen.ts","./src/api/core/types.gen.ts","./src/api/core/utils.gen.ts","./src/services/apiService.ts","./src/services/ocrService.ts","./src/stores/index.ts","./src/stores/market.ts","./src/stores/noita.ts","./src/stores/puzzles.ts","./src/stores/submissions.ts","./src/stores/uploads.ts","./src/stores/user.ts","./src/types/index.ts","./src/Home.vue","./src/Market.vue","./src/Noita.vue","./src/OpusMagnum.vue","./src/Submissions.vue","./src/components/AdminPanel.vue","./src/components/FileUpload.vue","./src/components/MarketCard.vue","./src/components/PuzzleCard.vue","./src/components/PuzzleResults.vue","./src/components/RankBadge.vue","./src/components/Results.vue","./src/components/SubmissionForm.vue","./src/components/TopUsersLeaderboard.vue","./src/components/UserBets.vue","./src/components/Winners.vue"],"errors":true,"version":"5.9.3"}
+1
View File
@@ -24,6 +24,7 @@ export default defineConfig({
home: resolve('./src/home.ts'),
noita: resolve('./src/noita.ts'),
market: resolve('./src/market.ts'),
submissions: resolve('./src/submissions.ts'),
}
}
},
+1
View File
@@ -26,6 +26,7 @@ export default defineConfig({
home: resolve('./src/home.ts'),
noita: resolve('./src/noita.ts'),
market: resolve('./src/market.ts'),
submissions: resolve('./src/submissions.ts'),
}
}
},
+29 -22
View File
@@ -4,32 +4,39 @@ version = "0.1.0"
description = "PolyLAN submitter with CAS authentication"
requires-python = ">=3.10"
dependencies = [
"django>=5.2.7",
"django-vite>=3.1.0",
"requests>=2.31.0",
"pillow>=10.0.0",
"gunicorn>=23.0.0",
"opencv-python>=4.12.0.88",
"pytesseract>=0.3.13",
"pandas>=2.3.3",
"django-shinobi>=1.4.0",
"psycopg>=3.2.13",
"sentry-sdk[django]>=2.59.0",
"furl>=2.1.4",
"django-redis>=6.0.0",
"django",
"django-vite",
"requests",
"pillow",
"gunicorn",
"daphne",
"opencv-python",
"pytesseract",
"pandas",
"django-shinobi",
"psycopg",
"sentry-sdk[django]",
"furl",
"django-redis",
"django-storages[s3]",
"whitenoise",
"django-channels",
"channels-redis",
"django-rq",
"django-tasks-rq",
]
[dependency-groups]
dev = [
"django-extensions>=4.1",
"django-stubs>=5.2.7",
"django-stubs-ext>=5.2.7",
"django-types>=0.22.0",
"ipython>=8.37.0",
"pre-commit>=4.3.0",
"pyjwt>=2.10.1",
"pyright>=1.1.407",
"ruff>=0.14.2",
"django-extensions",
"django-stubs",
"django-stubs-ext",
"django-types",
"ipython",
"pre-commit",
"pyjwt",
"pyright",
"ruff",
]
[tool.uv.workspace]
Generated
+1767 -521
View File
File diff suppressed because it is too large Load Diff