Compare commits
14
Commits
3e91ef77b6
...
d534519de6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d534519de6
|
||
|
|
d7bdb85fb0
|
||
|
|
b64161b371
|
||
|
|
c02a0aa985
|
||
|
|
202e04a554
|
||
|
|
0fc1fb255f
|
||
|
|
a769d1a0b3
|
||
|
|
11d6fc423a
|
||
|
|
be81d40a90
|
||
|
|
9f9cb0a483
|
||
|
|
6a58a0289e
|
||
|
|
c2602dbc02
|
||
|
|
f543cdcd5c
|
||
|
|
f2fe87c12b
|
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
return f"{self.name} ({self.steam_app_id})"
|
||||
if self.steam_app_id:
|
||||
return f"{self.name} ({self.steam_app_id})"
|
||||
return self.name
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
+39
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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})'}"
|
||||
@@ -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()),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
# Create your tests here.
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user