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
|
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)
|
@admin.register(Game)
|
||||||
class GameAdmin(admin.ModelAdmin):
|
class GameAdmin(admin.ModelAdmin):
|
||||||
list_display = ["name", "steam_app_id", "enabled", "updated_at"]
|
list_display = ["name", "steam_app_id", "enabled", "updated_at"]
|
||||||
list_filter = ["enabled"]
|
list_filter = ["enabled"]
|
||||||
search_fields = ["name", "steam_app_id"]
|
search_fields = ["name", "steam_app_id"]
|
||||||
readonly_fields = ["created_at", "updated_at"]
|
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):
|
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)
|
name = models.CharField(max_length=255)
|
||||||
path = models.CharField(max_length=100)
|
path = models.CharField(max_length=100)
|
||||||
|
image = models.URLField(null=True, blank=True)
|
||||||
enabled = models.BooleanField(default=True)
|
enabled = models.BooleanField(default=True)
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
@@ -14,4 +15,6 @@ class Game(models.Model):
|
|||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
|
if self.steam_app_id:
|
||||||
return f"{self.name} ({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
|
from ninja import Schema
|
||||||
|
|
||||||
|
|
||||||
class GameOut(Schema):
|
class GameOut(Schema):
|
||||||
steam_app_id: int
|
id: int
|
||||||
|
steam_app_id: Optional[int] = None
|
||||||
name: str
|
name: str
|
||||||
path: str
|
path: str
|
||||||
|
image: Optional[str] = None
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ from ninja import NinjaAPI
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.http import HttpRequest
|
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 opus_magnum.schemas import UserInfoOut
|
||||||
from animations.api import router as results_router
|
from animations.api import router as results_router
|
||||||
from noita.api import router as noita_router
|
from noita.api import router as noita_router
|
||||||
from games.api import router as games_router
|
from games.api import router as games_router
|
||||||
from market.api import router as market_router
|
from market.api import router as market_router
|
||||||
|
from submissions.api import router as submissions_router
|
||||||
|
|
||||||
# Create the main API instance
|
# Create the main API instance
|
||||||
api = NinjaAPI(
|
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
|
# Add authentication for protected endpoints
|
||||||
# api.auth = django_auth # Uncomment if you want global auth
|
# api.auth = django_auth # Uncomment if you want global auth
|
||||||
|
|
||||||
# Include the opus_magnum router
|
# Include the routers
|
||||||
api.add_router("/opus-magnum/", submissions_router, tags=["opus-magnum"])
|
api.add_router("/opus-magnum/", opus_magnum_router, tags=["opus-magnum"])
|
||||||
api.add_router("/results/", results_router, tags=["results"])
|
api.add_router("/results/", results_router, tags=["results"])
|
||||||
api.add_router("/noita/", noita_router, tags=["noita"])
|
api.add_router("/noita/", noita_router, tags=["noita"])
|
||||||
api.add_router("/games/", games_router, tags=["games"])
|
api.add_router("/games/", games_router, tags=["games"])
|
||||||
|
api.add_router("/submissions/", submissions_router, tags=["submissions"])
|
||||||
|
|
||||||
if settings.MARKET_ENABLED:
|
if settings.MARKET_ENABLED:
|
||||||
api.add_router("/market/", market_router)
|
api.add_router("/market/", market_router)
|
||||||
|
|||||||
@@ -10,7 +10,18 @@ https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
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")
|
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'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
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
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||||
@@ -32,6 +35,7 @@ ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
|
|||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
|
"daphne",
|
||||||
"django.contrib.admin",
|
"django.contrib.admin",
|
||||||
"django.contrib.auth",
|
"django.contrib.auth",
|
||||||
"django.contrib.contenttypes",
|
"django.contrib.contenttypes",
|
||||||
@@ -39,16 +43,20 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"django_vite",
|
"django_vite",
|
||||||
|
"django_rq",
|
||||||
|
"django_tasks_rq",
|
||||||
"accounts",
|
"accounts",
|
||||||
"animations",
|
"animations",
|
||||||
"opus_magnum",
|
"opus_magnum",
|
||||||
"noita",
|
"noita",
|
||||||
"games",
|
"games",
|
||||||
"market",
|
"market",
|
||||||
|
"submissions",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
@@ -75,7 +83,7 @@ TEMPLATES = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
WSGI_APPLICATION = "polylan_submitter.wsgi.application"
|
ASGI_APPLICATION = "polylan_submitter.asgi.application"
|
||||||
|
|
||||||
|
|
||||||
# Database
|
# 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 file types for submissions
|
||||||
ALLOWED_SUBMISSION_TYPES = [
|
ALLOWED_SUBMISSION_TYPES = [
|
||||||
"image/jpeg",
|
"image/jpeg",
|
||||||
@@ -195,6 +230,16 @@ STATICFILES_DIRS = [
|
|||||||
os.path.join(BASE_DIR, "static_source/vite"),
|
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
|
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 simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
|
||||||
from games.decorators import require_game_enabled
|
from games.decorators import require_game_enabled
|
||||||
from market.views import market_home
|
from market.views import market_home
|
||||||
|
from submissions.views import submission_home
|
||||||
from .api import api
|
from .api import api
|
||||||
|
|
||||||
NOITA_APP_ID = 881100
|
NOITA_APP_ID = 881100
|
||||||
@@ -72,6 +73,7 @@ urlpatterns = [
|
|||||||
path("market", market_home, name="market.home"),
|
path("market", market_home, name="market.home"),
|
||||||
path("opus-magnum", opus_magnum_home, name="opus-magnum.home"),
|
path("opus-magnum", opus_magnum_home, name="opus-magnum.home"),
|
||||||
path("noita", noita_home, name="noita.home"),
|
path("noita", noita_home, name="noita.home"),
|
||||||
|
path("submissions/<str:game>", submission_home, name="submissions.home"),
|
||||||
path("", home, name="home"),
|
path("", home, name="home"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,18 @@ const games = ref<GamesApiListGamesResponse | undefined>();
|
|||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const imageErrors = ref<Set<number>>(new Set());
|
const imageErrors = ref<Set<number>>(new Set());
|
||||||
|
|
||||||
const getHeaderImage = (appId: number) => {
|
const getHeaderImage = (game: GamesApiListGamesResponse[number]) => {
|
||||||
return `https://cdn.akamai.steamstatic.com/steam/apps/${appId}/header.jpg`;
|
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) => {
|
const onImageError = (gameId: number) => {
|
||||||
imageErrors.value.add(appId);
|
imageErrors.value.add(gameId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigate = (path: string) => {
|
const navigate = (path: string) => {
|
||||||
@@ -82,11 +88,11 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Game Cards -->
|
<!-- 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">
|
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">
|
<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"
|
<img v-if="getHeaderImage(game) && !imageErrors.has(game.id)" :src="getHeaderImage(game)!" :alt="game.name"
|
||||||
@error="onImageError(game.steam_app_id)" class="w-full h-full object-cover" />
|
@error="onImageError(game.id)" class="w-full h-full object-cover" />
|
||||||
<div v-else
|
<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">
|
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>
|
<i class="mdi mdi-gamepad-variant text-5xl"></i>
|
||||||
@@ -96,7 +102,7 @@ onMounted(async () => {
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-2xl">{{ game.name }}</h2>
|
<h2 class="card-title text-2xl">{{ game.name }}</h2>
|
||||||
<div class="card-actions justify-end mt-4">
|
<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>
|
<i class="mdi mdi-arrow-right mr-2"></i>
|
||||||
Submit results
|
Submit results
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ onMounted(() => {
|
|||||||
<h1 class="text-xl font-bold">Noita Submitter</h1>
|
<h1 class="text-xl font-bold">Noita Submitter</h1>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<a href="/api/docs" class="btn btn-xs">API docs</a>
|
<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>
|
||||||
</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
|
// 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 { 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, 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 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 { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
|
||||||
import { client } from './client.gen';
|
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> & {
|
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 });
|
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
|
* List Markets
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -341,120 +341,62 @@ export type PuzzleResponseOut = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* SubmissionFileOut
|
* SubmissionFileOut
|
||||||
*
|
|
||||||
* Schema for submission file output
|
|
||||||
*/
|
*/
|
||||||
export type SubmissionFileOut = {
|
export type SubmissionFileOut = {
|
||||||
/**
|
/**
|
||||||
* File Url
|
* Id
|
||||||
*/
|
*/
|
||||||
file_url: string | null;
|
id: string;
|
||||||
/**
|
|
||||||
* ID
|
|
||||||
*/
|
|
||||||
id: number;
|
|
||||||
/**
|
/**
|
||||||
* Original Filename
|
* Original Filename
|
||||||
*
|
|
||||||
* Original filename as uploaded by user
|
|
||||||
*/
|
*/
|
||||||
original_filename: string;
|
original_filename: string;
|
||||||
/**
|
|
||||||
* File Size
|
|
||||||
*
|
|
||||||
* File size in bytes
|
|
||||||
*/
|
|
||||||
file_size: number;
|
|
||||||
/**
|
/**
|
||||||
* Content Type
|
* Content Type
|
||||||
*
|
|
||||||
* MIME type of the file
|
|
||||||
*/
|
*/
|
||||||
content_type: string;
|
content_type: string;
|
||||||
/**
|
/**
|
||||||
* Ocr Processed
|
* File Size
|
||||||
*
|
|
||||||
* Whether OCR has been processed for this file
|
|
||||||
*/
|
*/
|
||||||
ocr_processed?: boolean;
|
file_size: number;
|
||||||
|
/**
|
||||||
|
* File Url
|
||||||
|
*/
|
||||||
|
file_url?: string | null;
|
||||||
|
/**
|
||||||
|
* Ocr Status
|
||||||
|
*/
|
||||||
|
ocr_status: string;
|
||||||
/**
|
/**
|
||||||
* Ocr Raw Data
|
* Ocr Raw Data
|
||||||
*
|
|
||||||
* Raw OCR data as JSON
|
|
||||||
*/
|
*/
|
||||||
ocr_raw_data?: {
|
ocr_raw_data: string;
|
||||||
[key: string]: unknown;
|
|
||||||
} | null;
|
|
||||||
/**
|
/**
|
||||||
* Ocr Error
|
* Crops
|
||||||
*
|
|
||||||
* OCR processing error message
|
|
||||||
*/
|
*/
|
||||||
ocr_error: string;
|
crops?: Array<ImageCropOut>;
|
||||||
/**
|
|
||||||
* Created At
|
|
||||||
*/
|
|
||||||
created_at: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SubmissionOut
|
* SubmissionOut
|
||||||
*
|
|
||||||
* Schema for submission output
|
|
||||||
*/
|
*/
|
||||||
export type SubmissionOut = {
|
export type SubmissionOut = {
|
||||||
/**
|
|
||||||
* Responses
|
|
||||||
*/
|
|
||||||
responses: Array<PuzzleResponseOut>;
|
|
||||||
/**
|
|
||||||
* Total Responses
|
|
||||||
*/
|
|
||||||
total_responses: number;
|
|
||||||
/**
|
|
||||||
* Needs Validation
|
|
||||||
*/
|
|
||||||
needs_validation: boolean;
|
|
||||||
/**
|
/**
|
||||||
* Id
|
* Id
|
||||||
*/
|
*/
|
||||||
id?: string;
|
id: string;
|
||||||
/**
|
/**
|
||||||
* User
|
* Game Id
|
||||||
*
|
|
||||||
* User who made the submission (null for anonymous)
|
|
||||||
*/
|
*/
|
||||||
user_id?: number | null;
|
game_id?: number | null;
|
||||||
/**
|
/**
|
||||||
* Notes
|
* Notes
|
||||||
*
|
|
||||||
* Optional notes about the submission
|
|
||||||
*/
|
*/
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
/**
|
/**
|
||||||
* Is Validated
|
* Files
|
||||||
*
|
|
||||||
* Whether this submission has been manually validated
|
|
||||||
*/
|
*/
|
||||||
is_validated?: boolean;
|
files?: Array<SubmissionFileOut>;
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
/**
|
/**
|
||||||
* Created At
|
* Created At
|
||||||
*/
|
*/
|
||||||
@@ -952,10 +894,14 @@ export type NoitaSubmissionOut = {
|
|||||||
* GameOut
|
* GameOut
|
||||||
*/
|
*/
|
||||||
export type GameOut = {
|
export type GameOut = {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
*/
|
||||||
|
id: number;
|
||||||
/**
|
/**
|
||||||
* Steam App Id
|
* Steam App Id
|
||||||
*/
|
*/
|
||||||
steam_app_id: number;
|
steam_app_id?: number | null;
|
||||||
/**
|
/**
|
||||||
* Name
|
* Name
|
||||||
*/
|
*/
|
||||||
@@ -964,6 +910,78 @@ export type GameOut = {
|
|||||||
* Path
|
* Path
|
||||||
*/
|
*/
|
||||||
path: string;
|
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 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 = {
|
export type MarketApiListMarketsData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: 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'),
|
home: resolve('./src/home.ts'),
|
||||||
noita: resolve('./src/noita.ts'),
|
noita: resolve('./src/noita.ts'),
|
||||||
market: resolve('./src/market.ts'),
|
market: resolve('./src/market.ts'),
|
||||||
|
submissions: resolve('./src/submissions.ts'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export default defineConfig({
|
|||||||
home: resolve('./src/home.ts'),
|
home: resolve('./src/home.ts'),
|
||||||
noita: resolve('./src/noita.ts'),
|
noita: resolve('./src/noita.ts'),
|
||||||
market: resolve('./src/market.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"
|
description = "PolyLAN submitter with CAS authentication"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"django>=5.2.7",
|
"django",
|
||||||
"django-vite>=3.1.0",
|
"django-vite",
|
||||||
"requests>=2.31.0",
|
"requests",
|
||||||
"pillow>=10.0.0",
|
"pillow",
|
||||||
"gunicorn>=23.0.0",
|
"gunicorn",
|
||||||
"opencv-python>=4.12.0.88",
|
"daphne",
|
||||||
"pytesseract>=0.3.13",
|
"opencv-python",
|
||||||
"pandas>=2.3.3",
|
"pytesseract",
|
||||||
"django-shinobi>=1.4.0",
|
"pandas",
|
||||||
"psycopg>=3.2.13",
|
"django-shinobi",
|
||||||
"sentry-sdk[django]>=2.59.0",
|
"psycopg",
|
||||||
"furl>=2.1.4",
|
"sentry-sdk[django]",
|
||||||
"django-redis>=6.0.0",
|
"furl",
|
||||||
|
"django-redis",
|
||||||
|
"django-storages[s3]",
|
||||||
|
"whitenoise",
|
||||||
|
"django-channels",
|
||||||
|
"channels-redis",
|
||||||
|
"django-rq",
|
||||||
|
"django-tasks-rq",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"django-extensions>=4.1",
|
"django-extensions",
|
||||||
"django-stubs>=5.2.7",
|
"django-stubs",
|
||||||
"django-stubs-ext>=5.2.7",
|
"django-stubs-ext",
|
||||||
"django-types>=0.22.0",
|
"django-types",
|
||||||
"ipython>=8.37.0",
|
"ipython",
|
||||||
"pre-commit>=4.3.0",
|
"pre-commit",
|
||||||
"pyjwt>=2.10.1",
|
"pyjwt",
|
||||||
"pyright>=1.1.407",
|
"pyright",
|
||||||
"ruff>=0.14.2",
|
"ruff",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv.workspace]
|
[tool.uv.workspace]
|
||||||
|
|||||||
Reference in New Issue
Block a user