Compare commits
61
Commits
9ee45463a8
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e91ef77b6
|
||
|
|
215d68a0e1
|
||
|
|
47812ffd09
|
||
|
|
a5fe8aacaf
|
||
|
|
b437210eb3
|
||
|
|
5584e54b58
|
||
|
|
35ea54ecea
|
||
|
|
821e453bc0
|
||
|
|
9fd0122a67
|
||
|
|
e557fe2cda
|
||
|
|
79e7cef3ba
|
||
|
|
a264336bd8
|
||
|
|
42e3571fab
|
||
|
|
43b314bb20
|
||
|
|
f1afb2096f
|
||
|
|
62a81e57ad
|
||
|
|
303b9e1c8a
|
||
|
|
f7c7eba4da
|
||
|
|
ce30539808
|
||
|
|
544112b204
|
||
|
|
9f94fb3974
|
||
|
|
25072e8eb4
|
||
|
|
9e5ab8539a
|
||
|
|
4ba6a48246
|
||
|
|
92dddca964
|
||
|
|
bf21a5eae6
|
||
|
|
89faa7cd40
|
||
|
|
a800c7fff7
|
||
|
|
2ee8cd6be3
|
||
|
|
35824de310
|
||
|
|
65cc2e555f
|
||
|
|
6d996a4e2f
|
||
|
|
f774ff3340
|
||
|
|
779393106d
|
||
|
|
3e04f8312a
|
||
|
|
7cfab20826
|
||
|
|
754b0b0803
|
||
|
|
79f469a393
|
||
|
|
5aeff9c218
|
||
|
|
2264401a91
|
||
|
|
9662181d4d
|
||
|
|
90f1ce13cf
|
||
|
|
f90377ac69
|
||
|
|
d8d09c21d4
|
||
|
|
3c86ca4c91
|
||
|
|
0b138e315c
|
||
|
|
d2a9dbe4a4
|
||
|
|
fa53d74295
|
||
|
|
52a6a4adb2
|
||
|
|
69b6b46ee2
|
||
|
|
119fdc2a51
|
||
|
|
01b0dbd1d9
|
||
|
|
19cc52c9f8
|
||
|
|
07f5c76a52
|
||
|
|
8584102402
|
||
|
|
404af4f90d
|
||
|
|
eb1eed852b
|
||
|
|
11229a3906
|
||
|
|
38e2b5e858
|
||
|
|
1e4251c796
|
||
|
|
fa76fbce92
|
@@ -1,78 +0,0 @@
|
||||
from ninja import NinjaAPI
|
||||
from submissions.api import router as submissions_router
|
||||
from submissions.schemas import UserInfoOut
|
||||
|
||||
# Create the main API instance
|
||||
api = NinjaAPI(
|
||||
title="Opus Magnum Submission API",
|
||||
version="1.0.0",
|
||||
description="""API for managing Opus Magnum puzzle submissions.
|
||||
|
||||
The Opus Magnum Submission API allows clients to upload, manage, validate, and review puzzle solution submissions for the Opus Magnum puzzle game community.
|
||||
It provides features for user authentication, puzzle listing, submission uploads, automated and manual OCR validation, and administrative workflows.
|
||||
""",
|
||||
openapi_extra={
|
||||
"info": {
|
||||
"contact": {
|
||||
"name": "Legrems",
|
||||
"email": "loic.gremaud@polylan.ch",
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Add authentication for protected endpoints
|
||||
# api.auth = django_auth # Uncomment if you want global auth
|
||||
|
||||
# Include the submissions router
|
||||
api.add_router("/submissions/", submissions_router, tags=["submissions"])
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@api.get("/health")
|
||||
def health_check(request):
|
||||
"""Health check endpoint"""
|
||||
return {"status": "healthy", "service": "opus-magnum-api"}
|
||||
|
||||
|
||||
# API info endpoint
|
||||
@api.get("/info")
|
||||
def api_info(request):
|
||||
"""Get API information"""
|
||||
return {
|
||||
"name": "Opus Magnum Submission API",
|
||||
"version": "1.0.0",
|
||||
"description": "API for managing puzzle submissions with OCR validation",
|
||||
"features": [
|
||||
"Multi-puzzle submissions",
|
||||
"OCR validation",
|
||||
"Manual validation workflow",
|
||||
"Admin validation tools",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# User info endpoint
|
||||
@api.get("/user", response=UserInfoOut)
|
||||
def get_user_info(request):
|
||||
"""Get current user information"""
|
||||
user = request.user
|
||||
|
||||
if user.is_authenticated:
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"email": user.email,
|
||||
"is_authenticated": True,
|
||||
"is_staff": user.is_staff,
|
||||
"is_superuser": user.is_superuser,
|
||||
"cas_groups": getattr(user, "cas_groups", []),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"is_authenticated": False,
|
||||
"is_staff": False,
|
||||
"is_superuser": False,
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
export interface SteamCollection {
|
||||
id: number
|
||||
steam_id: string
|
||||
title: string
|
||||
description: string
|
||||
author_name: string
|
||||
total_items: number
|
||||
unique_visitors: number
|
||||
current_favorites: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SteamCollectionItem {
|
||||
id: number
|
||||
steam_item_id: string
|
||||
title: string
|
||||
author_name: string
|
||||
description: string
|
||||
tags: string[]
|
||||
order_index: number
|
||||
collection: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface OpusMagnumData {
|
||||
puzzle: string
|
||||
cost: string
|
||||
cycles: string
|
||||
area: string
|
||||
confidence: {
|
||||
puzzle: number
|
||||
cost: number
|
||||
cycles: number
|
||||
area: number
|
||||
overall: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface SubmissionFile {
|
||||
file: File
|
||||
file_url: string
|
||||
preview: string
|
||||
type: 'image' | 'gif'
|
||||
ocrData?: OpusMagnumData
|
||||
ocrProcessing?: boolean
|
||||
ocrError?: string
|
||||
original_filename?: string
|
||||
manualPuzzleSelection?: string
|
||||
needsManualPuzzleSelection?: boolean
|
||||
}
|
||||
|
||||
export interface PuzzleResponse {
|
||||
id?: number
|
||||
// puzzle: number | SteamCollectionItem
|
||||
puzzle: number
|
||||
puzzle_name: string
|
||||
cost?: string
|
||||
cycles?: string
|
||||
area?: string
|
||||
needs_manual_validation?: boolean
|
||||
ocr_confidence_cost?: number
|
||||
ocr_confidence_cycles?: number
|
||||
ocr_confidence_area?: number
|
||||
validated_cost?: string
|
||||
validated_cycles?: string
|
||||
validated_area?: string
|
||||
final_cost?: string
|
||||
final_cycles?: string
|
||||
final_area?: string
|
||||
files?: SubmissionFile[]
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface Submission {
|
||||
id?: string
|
||||
user?: number | null
|
||||
responses: PuzzleResponse[]
|
||||
notes?: string
|
||||
is_validated?: boolean
|
||||
validated_by?: number | null
|
||||
validated_at?: string | null
|
||||
manual_validation_requested?: boolean
|
||||
total_responses?: number
|
||||
needs_validation?: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id?: number
|
||||
username?: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
email?: string
|
||||
is_authenticated: boolean
|
||||
is_staff: boolean
|
||||
is_superuser: boolean
|
||||
cas_groups?: string[]
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot": {
|
||||
"file": "assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot"
|
||||
},
|
||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf": {
|
||||
"file": "assets/materialdesignicons-webfont-B7mPwVP_.ttf",
|
||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf"
|
||||
},
|
||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff": {
|
||||
"file": "assets/materialdesignicons-webfont-PXm3-2wK.woff",
|
||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff"
|
||||
},
|
||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2": {
|
||||
"file": "assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
|
||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
|
||||
},
|
||||
"src/main.ts": {
|
||||
"file": "assets/main-CNlI4PW6.js",
|
||||
"name": "main",
|
||||
"src": "src/main.ts",
|
||||
"isEntry": true,
|
||||
"css": [
|
||||
"assets/main-HDjkw-xK.css"
|
||||
],
|
||||
"assets": [
|
||||
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
||||
"assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
|
||||
"assets/materialdesignicons-webfont-PXm3-2wK.woff",
|
||||
"assets/materialdesignicons-webfont-B7mPwVP_.ttf"
|
||||
]
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot": {
|
||||
"file": "assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot"
|
||||
},
|
||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf": {
|
||||
"file": "assets/materialdesignicons-webfont-B7mPwVP_.ttf",
|
||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf"
|
||||
},
|
||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff": {
|
||||
"file": "assets/materialdesignicons-webfont-PXm3-2wK.woff",
|
||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff"
|
||||
},
|
||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2": {
|
||||
"file": "assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
|
||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
|
||||
},
|
||||
"src/main.ts": {
|
||||
"file": "assets/main-NIi3b_aN.js",
|
||||
"name": "main",
|
||||
"src": "src/main.ts",
|
||||
"isEntry": true,
|
||||
"css": [
|
||||
"assets/main-CYuvChoP.css"
|
||||
],
|
||||
"assets": [
|
||||
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
||||
"assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
|
||||
"assets/materialdesignicons-webfont-PXm3-2wK.woff",
|
||||
"assets/materialdesignicons-webfont-B7mPwVP_.ttf"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"root":["./src/main.ts","./src/services/apiService.ts","./src/services/ocrService.ts","./src/stores/index.ts","./src/stores/puzzles.ts","./src/stores/submissions.ts","./src/types/index.ts","./src/App.vue","./src/components/AdminPanel.vue","./src/components/FileUpload.vue","./src/components/PuzzleCard.vue","./src/components/SubmissionForm.vue"],"version":"5.9.3"}
|
||||
@@ -34,3 +34,6 @@ class CustomUserAdmin(UserAdmin):
|
||||
return obj.get_cas_groups_display()
|
||||
|
||||
get_cas_groups_display.short_description = "CAS Groups"
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-23 18:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="customuser",
|
||||
name="points",
|
||||
field=models.IntegerField(default=1000),
|
||||
),
|
||||
]
|
||||
@@ -14,6 +14,9 @@ class CustomUser(AbstractUser):
|
||||
# Additional fields that might come from CAS
|
||||
cas_attributes = models.JSONField(default=dict, blank=True)
|
||||
|
||||
# Market points balance
|
||||
points = models.IntegerField(default=1000)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.username} ({self.cas_user_id})"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from django.contrib import admin
|
||||
from animations.models import PuzzlePointsFactor, PuzzlePointsValue
|
||||
|
||||
|
||||
@admin.register(PuzzlePointsFactor)
|
||||
class PuzzlePointsFactorAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id",
|
||||
"cost",
|
||||
"cycles",
|
||||
"area",
|
||||
"special_notes",
|
||||
]
|
||||
list_filter = ["cost", "cycles", "area", "special_notes"]
|
||||
search_fields = ["cost", "cycles", "area", "special_notes"]
|
||||
readonly_fields = ["created_at", "updated_at"]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Basic Information",
|
||||
{"fields": ("cost", "cycles", "area")},
|
||||
),
|
||||
(
|
||||
"Special notes",
|
||||
{
|
||||
"fields": ("special_notes",),
|
||||
"description": "Special notes about the puzzle. May be some extra restriction, etc...",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Metadata",
|
||||
{
|
||||
"fields": ("created_at", "updated_at"),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(PuzzlePointsValue)
|
||||
class PuzzlePointsValueAdmin(admin.ModelAdmin):
|
||||
list_display = ["id", "points"]
|
||||
list_filter = ["points"]
|
||||
search_fields = ["points"]
|
||||
readonly_fields = ["created_at", "updated_at"]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Basic Information",
|
||||
{"fields": ("points",)},
|
||||
),
|
||||
(
|
||||
"Metadata",
|
||||
{
|
||||
"fields": ("created_at", "updated_at"),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,241 @@
|
||||
from collections import defaultdict
|
||||
from django.http import HttpRequest
|
||||
from ninja import Router
|
||||
from ninja.errors import HttpError
|
||||
from django.core.cache import cache
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from accounts.models import CustomUser
|
||||
from animations.schemas import (
|
||||
RankingSchema,
|
||||
TournamentSubmissionsOut,
|
||||
TournamentPuzzleResultsOut,
|
||||
PuzzleSubmissionsOut,
|
||||
PuzzleResultsOut,
|
||||
PuzzleSubmissionWithRankOut,
|
||||
PuzzlePointsFactorOut,
|
||||
WinnerResponseOut,
|
||||
WinnerFileOut,
|
||||
)
|
||||
from opus_magnum.models import PuzzleResponse, SteamCollectionItem, SteamCollection
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.get("results", response=RankingSchema)
|
||||
def results(request: HttpRequest) -> dict:
|
||||
cache_key = "api:results:results"
|
||||
cached_data = cache.get(cache_key)
|
||||
|
||||
if cached_data is not None:
|
||||
return cached_data
|
||||
|
||||
responses_by_userid = defaultdict(list)
|
||||
responses_by_puzzleid = defaultdict(list)
|
||||
|
||||
for response in list(
|
||||
PuzzleResponse.objects.filter(needs_manual_validation=False)
|
||||
.filter_user_best_response()
|
||||
.prefetch_related("submission__user")
|
||||
):
|
||||
responses_by_userid[response.submission.user.id].append(response)
|
||||
responses_by_puzzleid[response.puzzle.id].append(response)
|
||||
|
||||
ranking = {}
|
||||
|
||||
for puzzle_id, responses in responses_by_puzzleid.items():
|
||||
ranking[puzzle_id] = sorted(
|
||||
responses, key=lambda x: (x.rank_points is None, x.rank_points or 0)
|
||||
)
|
||||
|
||||
data = {
|
||||
"users": list(CustomUser.objects.filter(pk__in=responses_by_userid.keys())),
|
||||
"puzzles": list(SteamCollectionItem.objects.all()),
|
||||
"responses_by_userid": dict(responses_by_userid),
|
||||
"ranking_by_puzzle": ranking,
|
||||
}
|
||||
|
||||
cache.set("api:results:results", data, 300)
|
||||
return data
|
||||
|
||||
|
||||
@router.get("top-submissions", response=TournamentSubmissionsOut)
|
||||
def top_submissions(request: HttpRequest, limit: int = 5) -> dict:
|
||||
"""Get tournament top submissions for each puzzle. Only available when tournament is closed."""
|
||||
cache_key = f"api:results:top_submissions:{limit}"
|
||||
cached_data = cache.get(cache_key)
|
||||
|
||||
if cached_data is not None:
|
||||
return cached_data
|
||||
|
||||
collection = get_object_or_404(SteamCollection, is_active=True)
|
||||
|
||||
# Only allow access when tournament is closed
|
||||
if collection.accepting_submissions:
|
||||
raise HttpError(403, "Tournament is still accepting submissions")
|
||||
|
||||
# Get all puzzles
|
||||
puzzles = SteamCollectionItem.objects.filter(collection=collection).order_by(
|
||||
"order_index"
|
||||
)
|
||||
|
||||
# Build response
|
||||
submissions_list = []
|
||||
for puzzle in puzzles:
|
||||
# Get the top N responses for this puzzle (ranked by points, highest first)
|
||||
top_responses = (
|
||||
PuzzleResponse.objects.filter(puzzle=puzzle, needs_manual_validation=False)
|
||||
.filter_user_best_response()
|
||||
.annotate_rank_points()
|
||||
.order_by("-rank_points")[:limit]
|
||||
)
|
||||
|
||||
# Build submission list for this puzzle
|
||||
puzzle_submissions = []
|
||||
for response in top_responses:
|
||||
# Get submission files
|
||||
files = response.files.all()
|
||||
response_files = [
|
||||
WinnerFileOut(
|
||||
file_url=file.file_url or "",
|
||||
original_filename=file.original_filename,
|
||||
)
|
||||
for file in files
|
||||
]
|
||||
|
||||
# Calculate total coefficient
|
||||
total_coef = None
|
||||
if (
|
||||
puzzle.points_factor
|
||||
and response.final_cost is not None
|
||||
and response.final_cycles is not None
|
||||
and response.final_area is not None
|
||||
):
|
||||
total_coef = (
|
||||
puzzle.points_factor.cost * response.final_cost
|
||||
+ puzzle.points_factor.cycles * response.final_cycles
|
||||
+ puzzle.points_factor.area * response.final_area
|
||||
)
|
||||
|
||||
submission_data = WinnerResponseOut(
|
||||
user_id=response.submission.user.id if response.submission.user else 0,
|
||||
username=response.submission.user.username
|
||||
if response.submission.user
|
||||
else "Anonymous",
|
||||
final_cost=response.final_cost,
|
||||
final_cycles=response.final_cycles,
|
||||
final_area=response.final_area,
|
||||
rank_points=response.rank_points,
|
||||
total_coef=total_coef,
|
||||
files=response_files,
|
||||
)
|
||||
puzzle_submissions.append(submission_data)
|
||||
|
||||
submissions_list.append(
|
||||
PuzzleSubmissionsOut(
|
||||
puzzle_id=puzzle.id,
|
||||
puzzle_title=puzzle.title,
|
||||
submissions=puzzle_submissions,
|
||||
)
|
||||
)
|
||||
|
||||
data = {"submissions": submissions_list}
|
||||
cache.set(f"api:results:top_submissions:{limit}", data, 300)
|
||||
return data
|
||||
|
||||
|
||||
@router.get("puzzle-results", response=TournamentPuzzleResultsOut)
|
||||
def puzzle_results(request: HttpRequest, limit: int = 5) -> dict:
|
||||
"""Get tournament results organized by puzzle with coefficients. Only available when tournament is closed."""
|
||||
cache_key = f"api:results:puzzle_results:{limit}"
|
||||
cached_data = cache.get(cache_key)
|
||||
|
||||
if cached_data is not None:
|
||||
return cached_data
|
||||
|
||||
collection = get_object_or_404(SteamCollection, is_active=True)
|
||||
|
||||
# Only allow access when tournament is closed
|
||||
if collection.accepting_submissions:
|
||||
raise HttpError(403, "Tournament is still accepting submissions")
|
||||
|
||||
# Get all puzzles
|
||||
puzzles = SteamCollectionItem.objects.filter(collection=collection).order_by(
|
||||
"order_index"
|
||||
)
|
||||
|
||||
# Build response
|
||||
results_list = []
|
||||
for puzzle in puzzles:
|
||||
# Get the top N responses for this puzzle (ranked by points)
|
||||
top_responses = (
|
||||
PuzzleResponse.objects.filter(puzzle=puzzle, needs_manual_validation=False)
|
||||
.filter_user_best_response()
|
||||
.annotate_rank_points()
|
||||
.order_by("-rank_points")[:limit]
|
||||
)
|
||||
|
||||
# Build submission list for this puzzle with rank
|
||||
puzzle_submissions = []
|
||||
for rank, response in enumerate(top_responses, 1):
|
||||
# Get submission files
|
||||
files = response.files.all()
|
||||
response_files = [
|
||||
WinnerFileOut(
|
||||
file_url=file.file_url or "",
|
||||
original_filename=file.original_filename,
|
||||
)
|
||||
for file in files
|
||||
]
|
||||
|
||||
# Calculate total coefficient
|
||||
total_coef = None
|
||||
if (
|
||||
puzzle.points_factor
|
||||
and response.final_cost is not None
|
||||
and response.final_cycles is not None
|
||||
and response.final_area is not None
|
||||
):
|
||||
total_coef = (
|
||||
puzzle.points_factor.cost * response.final_cost
|
||||
+ puzzle.points_factor.cycles * response.final_cycles
|
||||
+ puzzle.points_factor.area * response.final_area
|
||||
)
|
||||
|
||||
submission_data = PuzzleSubmissionWithRankOut(
|
||||
rank=rank,
|
||||
user_id=response.submission.user.id if response.submission.user else 0,
|
||||
username=response.submission.user.username
|
||||
if response.submission.user
|
||||
else "Anonymous",
|
||||
final_cost=response.final_cost,
|
||||
final_cycles=response.final_cycles,
|
||||
final_area=response.final_area,
|
||||
rank_points=response.rank_points,
|
||||
total_coef=total_coef,
|
||||
files=response_files,
|
||||
)
|
||||
puzzle_submissions.append(submission_data)
|
||||
|
||||
# Get points factor if available
|
||||
points_factor = None
|
||||
if puzzle.points_factor:
|
||||
points_factor = PuzzlePointsFactorOut(
|
||||
cost=puzzle.points_factor.cost,
|
||||
cycles=puzzle.points_factor.cycles,
|
||||
area=puzzle.points_factor.area,
|
||||
)
|
||||
|
||||
results_list.append(
|
||||
PuzzleResultsOut(
|
||||
puzzle_id=puzzle.id,
|
||||
puzzle_title=puzzle.title,
|
||||
points_factor=points_factor,
|
||||
submissions=puzzle_submissions,
|
||||
)
|
||||
)
|
||||
|
||||
data = {"results": results_list}
|
||||
cache.set(f"api:results:puzzle_results:{limit}", data, 300)
|
||||
return data
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SubmissionsConfig(AppConfig):
|
||||
class AnimationsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "submissions"
|
||||
name = "animations"
|
||||
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-23 22:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="PuzzlePointsFactor",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("cost", models.IntegerField()),
|
||||
("cycles", models.IntegerField()),
|
||||
("area", models.IntegerField()),
|
||||
("special_notes", models.TextField(blank=True)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-24 01:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("animations", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="PuzzlePointsValue",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("points", models.JSONField(default=[])),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-05 13:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("animations", "0002_puzzlepointsvalue"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="puzzlepointsvalue",
|
||||
name="points",
|
||||
field=models.JSONField(default=list),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class PuzzlePointsFactor(models.Model):
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
cost = models.IntegerField()
|
||||
cycles = models.IntegerField()
|
||||
area = models.IntegerField()
|
||||
|
||||
special_notes = models.TextField(blank=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.cost} - {self.cycles} - {self.area}"
|
||||
|
||||
|
||||
class PuzzlePointsValue(models.Model):
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
points = models.JSONField(default=list)
|
||||
@@ -0,0 +1,115 @@
|
||||
from ninja import ModelSchema, Schema
|
||||
from typing import List, Optional
|
||||
|
||||
from opus_magnum.models import PuzzleResponse
|
||||
from opus_magnum.schemas import SteamCollectionItemOut
|
||||
|
||||
|
||||
class PuzzleResponseRankingOut(ModelSchema):
|
||||
class Meta:
|
||||
model = PuzzleResponse
|
||||
fields = [
|
||||
"id",
|
||||
"puzzle_name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
points: int | None = None
|
||||
rank_points: int | None = None
|
||||
puzzle_user_rank: int
|
||||
user_response_rank: int
|
||||
|
||||
user_id: int
|
||||
|
||||
final_cost: int | None
|
||||
final_cycles: int | None
|
||||
final_area: int | None
|
||||
|
||||
@staticmethod
|
||||
def resolve_user_id(obj) -> int:
|
||||
return obj.submission.user.id
|
||||
|
||||
|
||||
class UserDisplayOut(Schema):
|
||||
id: int
|
||||
username: str
|
||||
is_staff: bool
|
||||
|
||||
|
||||
class RankingSchema(Schema):
|
||||
users: list[UserDisplayOut]
|
||||
puzzles: list[SteamCollectionItemOut]
|
||||
responses_by_userid: dict[int, list[PuzzleResponseRankingOut]]
|
||||
ranking_by_puzzle: dict[int, list[PuzzleResponseRankingOut]]
|
||||
|
||||
|
||||
class WinnerFileOut(Schema):
|
||||
"""Schema for winner submission file"""
|
||||
|
||||
file_url: str
|
||||
original_filename: str
|
||||
|
||||
|
||||
class WinnerResponseOut(Schema):
|
||||
"""Schema for winner response with files"""
|
||||
|
||||
user_id: int
|
||||
username: str
|
||||
final_cost: Optional[int]
|
||||
final_cycles: Optional[int]
|
||||
final_area: Optional[int]
|
||||
rank_points: Optional[int]
|
||||
total_coef: Optional[int]
|
||||
files: List[WinnerFileOut]
|
||||
|
||||
|
||||
class PuzzleSubmissionsOut(Schema):
|
||||
"""Schema for puzzle with all top submissions"""
|
||||
|
||||
puzzle_id: int
|
||||
puzzle_title: str
|
||||
submissions: List[WinnerResponseOut]
|
||||
|
||||
|
||||
class TournamentSubmissionsOut(Schema):
|
||||
"""Schema for tournament top submissions results"""
|
||||
|
||||
submissions: List[PuzzleSubmissionsOut]
|
||||
|
||||
|
||||
class PuzzlePointsFactorOut(Schema):
|
||||
"""Schema for puzzle points factor"""
|
||||
|
||||
cost: int
|
||||
cycles: int
|
||||
area: int
|
||||
|
||||
|
||||
class PuzzleSubmissionWithRankOut(Schema):
|
||||
"""Schema for puzzle submission with rank"""
|
||||
|
||||
rank: int
|
||||
user_id: int
|
||||
username: str
|
||||
final_cost: Optional[int]
|
||||
final_cycles: Optional[int]
|
||||
final_area: Optional[int]
|
||||
rank_points: Optional[int]
|
||||
total_coef: Optional[int]
|
||||
files: List[WinnerFileOut]
|
||||
|
||||
|
||||
class PuzzleResultsOut(Schema):
|
||||
"""Schema for puzzle-specific results with coefficients"""
|
||||
|
||||
puzzle_id: int
|
||||
puzzle_title: str
|
||||
points_factor: Optional[PuzzlePointsFactorOut]
|
||||
submissions: List[PuzzleSubmissionWithRankOut]
|
||||
|
||||
|
||||
class TournamentPuzzleResultsOut(Schema):
|
||||
"""Schema for tournament puzzle-specific results"""
|
||||
|
||||
results: List[PuzzleResultsOut]
|
||||
@@ -0,0 +1,11 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Game
|
||||
|
||||
|
||||
@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"]
|
||||
@@ -0,0 +1,13 @@
|
||||
from typing import List
|
||||
|
||||
from ninja import Router
|
||||
|
||||
from .models import Game
|
||||
from .schemas import GameOut
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.get("", response=List[GameOut])
|
||||
def list_games(request):
|
||||
return Game.objects.filter(enabled=True)
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class GamesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "games"
|
||||
@@ -0,0 +1,22 @@
|
||||
from functools import wraps
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
|
||||
from .models import Game
|
||||
|
||||
|
||||
def require_game_enabled(steam_app_id: int):
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(request, *args, **kwargs):
|
||||
try:
|
||||
game = Game.objects.get(steam_app_id=steam_app_id)
|
||||
except Game.DoesNotExist:
|
||||
raise PermissionDenied
|
||||
if not game.enabled:
|
||||
raise PermissionDenied
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,32 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Game",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("steam_app_id", models.PositiveIntegerField(unique=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("enabled", models.BooleanField(default=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["name"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
from django.db import migrations
|
||||
|
||||
NOITA_APP_ID = 881100
|
||||
OPUS_MAGNUM_APP_ID = 558990
|
||||
|
||||
|
||||
def seed_games(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Game.objects.get_or_create(
|
||||
steam_app_id=NOITA_APP_ID,
|
||||
defaults={"name": "Noita", "enabled": True},
|
||||
)
|
||||
Game.objects.get_or_create(
|
||||
steam_app_id=OPUS_MAGNUM_APP_ID,
|
||||
defaults={"name": "Opus Magnum", "enabled": True},
|
||||
)
|
||||
|
||||
|
||||
def unseed_games(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Game.objects.filter(steam_app_id__in=[NOITA_APP_ID, OPUS_MAGNUM_APP_ID]).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed_games, reverse_code=unseed_games),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
NOITA_APP_ID = 881100
|
||||
OPUS_MAGNUM_APP_ID = 558990
|
||||
|
||||
PATHS = {
|
||||
NOITA_APP_ID: "/noita",
|
||||
OPUS_MAGNUM_APP_ID: "/opus-magnum",
|
||||
}
|
||||
|
||||
|
||||
def set_paths(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
for app_id, path in PATHS.items():
|
||||
Game.objects.filter(steam_app_id=app_id).update(path=path)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0002_seed_noita_and_opus_magnum"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="path",
|
||||
field=models.CharField(default="", max_length=100),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.RunPython(set_paths, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Game(models.Model):
|
||||
steam_app_id = models.PositiveIntegerField(unique=True)
|
||||
name = models.CharField(max_length=255)
|
||||
path = models.CharField(max_length=100)
|
||||
enabled = models.BooleanField(default=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} ({self.steam_app_id})"
|
||||
@@ -0,0 +1,7 @@
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class GameOut(Schema):
|
||||
steam_app_id: int
|
||||
name: str
|
||||
path: str
|
||||
@@ -7,7 +7,7 @@ import sys
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "opus_submitter.settings")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polylan_submitter.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
@@ -0,0 +1,92 @@
|
||||
from django.contrib import admin
|
||||
from market.models import Market, MarketOption, UserBet, UserPointChange
|
||||
|
||||
|
||||
class MarketOptionInline(admin.TabularInline):
|
||||
model = MarketOption
|
||||
extra = 1
|
||||
fields = ["text"]
|
||||
|
||||
|
||||
@admin.register(Market)
|
||||
class MarketAdmin(admin.ModelAdmin):
|
||||
list_display = ["title", "status", "end_date", "created_by", "created_at"]
|
||||
list_filter = ["status", "created_at"]
|
||||
search_fields = ["uuid", "title"]
|
||||
readonly_fields = [
|
||||
"uuid",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"winning_option",
|
||||
]
|
||||
inlines = [MarketOptionInline]
|
||||
fieldsets = (
|
||||
("Info", {"fields": ["uuid", "title", "description"]}),
|
||||
("Configuration", {"fields": ["end_date", "multiplier"]}),
|
||||
("Status", {"fields": ["status", "winning_option"]}),
|
||||
("Metadata", {"fields": ["created_by", "created_at", "updated_at"]}),
|
||||
)
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
# Prevent any changes to resolved markets
|
||||
if obj and obj.status == Market.Status.RESOLVED:
|
||||
return False
|
||||
return super().has_change_permission(request, obj)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not change: # Creating new market
|
||||
obj.created_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
@admin.action(description="Publish selected draft markets")
|
||||
def publish_markets(self, request, queryset):
|
||||
updated = queryset.filter(status=Market.Status.DRAFT).update(
|
||||
status=Market.Status.OPEN
|
||||
)
|
||||
self.message_user(request, f"Published {updated} market(s).")
|
||||
|
||||
@admin.action(description="Close selected markets")
|
||||
def close_markets(self, request, queryset):
|
||||
updated = queryset.filter(status=Market.Status.OPEN).update(
|
||||
status=Market.Status.CLOSED
|
||||
)
|
||||
self.message_user(request, f"Closed {updated} market(s).")
|
||||
|
||||
actions = ["publish_markets", "close_markets"]
|
||||
|
||||
|
||||
@admin.register(MarketOption)
|
||||
class MarketOptionAdmin(admin.ModelAdmin):
|
||||
list_display = ["text", "market"]
|
||||
list_filter = ["market"]
|
||||
search_fields = ["uuid", "text", "market__title"]
|
||||
readonly_fields = ["uuid"]
|
||||
|
||||
|
||||
@admin.register(UserBet)
|
||||
class UserBetAdmin(admin.ModelAdmin):
|
||||
list_display = ["user", "option", "amount", "created_at"]
|
||||
list_filter = ["user", "created_at", "option__market"]
|
||||
search_fields = ["uuid", "user__username", "option__text"]
|
||||
readonly_fields = ["uuid", "user", "option", "amount", "created_at", "updated_at"]
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(UserPointChange)
|
||||
class UserPointChangeAdmin(admin.ModelAdmin):
|
||||
list_display = ["user", "market", "amount", "reason", "created_at"]
|
||||
list_filter = ["user", "reason", "created_at", "market"]
|
||||
search_fields = ["uuid", "user__username", "market__title"]
|
||||
readonly_fields = ["uuid", "created_at", "updated_at"]
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
@@ -0,0 +1,184 @@
|
||||
from typing import List
|
||||
from ninja import Router
|
||||
from ninja.errors import HttpError
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db.models import Sum, Prefetch
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db import transaction
|
||||
|
||||
from market.models import Market, MarketOption, UserBet, UserPointChange
|
||||
from market.schemas import (
|
||||
MarketListSchema,
|
||||
ResolveMarketSchema,
|
||||
UserBetCreateSchema,
|
||||
UserBetSchema,
|
||||
)
|
||||
|
||||
|
||||
router = Router(tags=["market"])
|
||||
|
||||
|
||||
@router.get("/", response=List[MarketListSchema])
|
||||
def list_markets(request):
|
||||
"""List all markets (excludes draft markets)."""
|
||||
markets = Market.objects.exclude(status=Market.Status.DRAFT)
|
||||
# Prefetch options with total_bets annotation sorted by total_bets desc, then text asc
|
||||
options_queryset = MarketOption.objects.annotate(
|
||||
total_bets=Coalesce(Sum("user_bets__amount"), 0)
|
||||
).order_by("-total_bets", "text")
|
||||
|
||||
return markets.prefetch_related(Prefetch("options", queryset=options_queryset))
|
||||
|
||||
|
||||
@router.get("/user/bets", response=List[UserBetSchema])
|
||||
def list_user_bets(request):
|
||||
"""List all bets placed by the current user."""
|
||||
if not request.user.is_authenticated:
|
||||
raise HttpError(401, "Authentication required")
|
||||
|
||||
return (
|
||||
UserBet.objects.filter(user=request.user)
|
||||
.select_related("option__market")
|
||||
.prefetch_related("option__market__options")
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{market_uuid}/actions/close")
|
||||
def close_market(request, market_uuid: str):
|
||||
"""Close a market. Admin only."""
|
||||
if not request.user.is_staff:
|
||||
raise HttpError(403, "Permission denied")
|
||||
|
||||
market = get_object_or_404(Market, uuid=market_uuid)
|
||||
market.status = Market.Status.CLOSED
|
||||
market.save(update_fields=["status", "updated_at"])
|
||||
return {"status": Market.Status.CLOSED}
|
||||
|
||||
|
||||
@router.post("/{market_uuid}/actions/resolve", response=MarketListSchema)
|
||||
def resolve_market(request, market_uuid: str, payload: ResolveMarketSchema):
|
||||
"""Resolve a market with a winning option. Admin only."""
|
||||
if not request.user.is_staff:
|
||||
raise HttpError(403, "Permission denied")
|
||||
|
||||
market = get_object_or_404(Market, uuid=market_uuid)
|
||||
winning_option = get_object_or_404(MarketOption, uuid=payload.winning_option_uuid)
|
||||
|
||||
if winning_option.market_id != market.id:
|
||||
raise HttpError(400, "Option does not belong to this market")
|
||||
|
||||
market.winning_option = winning_option
|
||||
market.status = Market.Status.RESOLVED
|
||||
market.save(update_fields=["winning_option", "status", "updated_at"])
|
||||
|
||||
# Calculate and distribute winnings
|
||||
all_bets = list(
|
||||
UserBet.objects.filter(option__market=market).select_related("user")
|
||||
)
|
||||
|
||||
# Calculate total pot
|
||||
total_pot = sum(bet.amount for bet in all_bets)
|
||||
if total_pot == 0:
|
||||
return market
|
||||
|
||||
# Separate winning and losing bets
|
||||
winning_bets = [bet for bet in all_bets if bet.option_id == winning_option.id]
|
||||
losing_bets = [bet for bet in all_bets if bet.option_id != winning_option.id]
|
||||
|
||||
total_winning = sum(bet.amount for bet in winning_bets)
|
||||
|
||||
point_changes = []
|
||||
users_to_update = []
|
||||
|
||||
with transaction.atomic():
|
||||
# Award payouts to winners with multiplier
|
||||
if total_winning > 0:
|
||||
for bet in winning_bets:
|
||||
payout = round(
|
||||
bet.amount / total_winning * total_pot * market.multiplier
|
||||
)
|
||||
bet.user.points += payout
|
||||
users_to_update.append(bet.user)
|
||||
point_changes.append(
|
||||
UserPointChange(
|
||||
user=bet.user,
|
||||
market=market,
|
||||
amount=payout,
|
||||
reason=UserPointChange.Reason.BET_WON,
|
||||
)
|
||||
)
|
||||
|
||||
# Record losing bets (points already deducted)
|
||||
for bet in losing_bets:
|
||||
point_changes.append(
|
||||
UserPointChange(
|
||||
user=bet.user,
|
||||
market=market,
|
||||
amount=-bet.amount,
|
||||
reason=UserPointChange.Reason.BET_LOST,
|
||||
)
|
||||
)
|
||||
|
||||
# Bulk update users
|
||||
for user in users_to_update:
|
||||
user.save(update_fields=["points"])
|
||||
|
||||
# Bulk create point changes
|
||||
UserPointChange.objects.bulk_create(point_changes)
|
||||
|
||||
return market
|
||||
|
||||
|
||||
@router.post("/{market_uuid}/bets", response=UserBetSchema)
|
||||
def create_bet(request, market_uuid: str, payload: UserBetCreateSchema):
|
||||
"""Place a bet on a market option."""
|
||||
if not request.user.is_authenticated:
|
||||
raise HttpError(401, "Authentication required")
|
||||
|
||||
market = get_object_or_404(Market, uuid=market_uuid)
|
||||
option = get_object_or_404(MarketOption, uuid=payload.option_uuid)
|
||||
|
||||
if option.market_id != market.id:
|
||||
raise HttpError(400, "Option does not belong to this market")
|
||||
|
||||
if market.status != Market.Status.OPEN:
|
||||
raise HttpError(400, "Market is not open for betting")
|
||||
|
||||
# Check if user already has a bet on a different option in this market
|
||||
existing_bet_on_market = (
|
||||
UserBet.objects.filter(user=request.user, option__market=market)
|
||||
.exclude(option=option)
|
||||
.first()
|
||||
)
|
||||
if existing_bet_on_market:
|
||||
raise HttpError(400, "You can only bet on one option per market")
|
||||
|
||||
# Check if user already has a bet on this option
|
||||
existing_bet = UserBet.objects.filter(user=request.user, option=option).first()
|
||||
if existing_bet and payload.amount < existing_bet.amount:
|
||||
raise HttpError(400, "Cannot decrease bet amount. You can only increase it.")
|
||||
|
||||
# Calculate delta (amount to deduct from user's points)
|
||||
delta = payload.amount - (existing_bet.amount if existing_bet else 0)
|
||||
|
||||
# Check if user has enough points
|
||||
if request.user.points < delta:
|
||||
raise HttpError(400, "Insufficient points for this bet")
|
||||
|
||||
user_bet, created = UserBet.objects.update_or_create(
|
||||
user=request.user,
|
||||
option=option,
|
||||
defaults={"amount": payload.amount},
|
||||
)
|
||||
|
||||
# Deduct points and record the change
|
||||
request.user.points -= delta
|
||||
request.user.save(update_fields=["points"])
|
||||
UserPointChange.objects.create(
|
||||
user=request.user,
|
||||
market=market,
|
||||
amount=-delta,
|
||||
reason=UserPointChange.Reason.BET_PLACED,
|
||||
)
|
||||
|
||||
return user_bet
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MarketConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "market"
|
||||
@@ -0,0 +1,187 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-23 15:45
|
||||
|
||||
import django.db.models.deletion
|
||||
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="Market",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("title", models.CharField(max_length=255)),
|
||||
("description", models.TextField(blank=True)),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
choices=[("yes_no", "Yes/No"), ("multiple", "Multiple Choice")],
|
||||
default="yes_no",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("open", "Open"),
|
||||
("closed", "Closed"),
|
||||
("resolved", "Resolved"),
|
||||
],
|
||||
default="open",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
("end_date", models.DateTimeField()),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="MarketOption",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("text", models.CharField(max_length=255)),
|
||||
("position", models.PositiveIntegerField(default=0)),
|
||||
(
|
||||
"market",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="options",
|
||||
to="market.market",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["position"],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="market",
|
||||
name="winning_option",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="market_won",
|
||||
to="market.marketoption",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserBet",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("amount", models.PositiveIntegerField()),
|
||||
(
|
||||
"option",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="user_bets",
|
||||
to="market.marketoption",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="bets",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="marketoption",
|
||||
index=models.Index(
|
||||
fields=["market", "position"], name="market_mark_market__8679ce_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="marketoption",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("market", "position"), name="unique_market_option_position"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="market",
|
||||
index=models.Index(
|
||||
fields=["status", "-created_at"], name="market_mark_status_1ef6c3_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="market",
|
||||
index=models.Index(
|
||||
fields=["end_date"], name="market_mark_end_dat_26bec0_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="userbet",
|
||||
index=models.Index(
|
||||
fields=["user", "option"], name="market_user_user_id_5e43d9_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="userbet",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("user", "option"), name="unique_user_bet_per_option"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,73 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-23 18:11
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("market", "0001_initial"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="UserPointChange",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("amount", models.IntegerField()),
|
||||
(
|
||||
"reason",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("bet_placed", "Bet Placed"),
|
||||
("bet_won", "Bet Won"),
|
||||
("bet_lost", "Bet Lost"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"market",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="point_changes",
|
||||
to="market.market",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="point_changes",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["user", "-created_at"],
|
||||
name="market_user_user_id_631ba9_idx",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-23 18:19
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("market", "0002_userpointchange"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="market",
|
||||
name="type",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-23 18:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("market", "0003_remove_market_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="marketoption",
|
||||
options={"ordering": ["text"]},
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name="marketoption",
|
||||
name="unique_market_option_position",
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name="marketoption",
|
||||
name="market_mark_market__8679ce_idx",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="marketoption",
|
||||
name="position",
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="marketoption",
|
||||
index=models.Index(
|
||||
fields=["market"], name="market_mark_market__67f63b_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="marketoption",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("market", "text"), name="unique_market_option_text"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-23 18:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("market", "0004_alter_marketoption_options_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="market",
|
||||
name="multiplier",
|
||||
field=models.FloatField(default=1.0),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-23 18:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("market", "0005_market_multiplier"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="market",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("draft", "Draft"),
|
||||
("open", "Open"),
|
||||
("closed", "Closed"),
|
||||
("resolved", "Resolved"),
|
||||
],
|
||||
default="draft",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,114 @@
|
||||
import uuid
|
||||
from django.db import models
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class Market(BaseModel):
|
||||
class Status(models.TextChoices):
|
||||
DRAFT = "draft", "Draft"
|
||||
OPEN = "open", "Open"
|
||||
CLOSED = "closed", "Closed"
|
||||
RESOLVED = "resolved", "Resolved"
|
||||
|
||||
title = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
status = models.CharField(
|
||||
max_length=10, choices=Status.choices, default=Status.DRAFT
|
||||
)
|
||||
end_date = models.DateTimeField()
|
||||
multiplier = models.FloatField(default=1.0)
|
||||
created_by = models.ForeignKey("accounts.CustomUser", on_delete=models.PROTECT)
|
||||
winning_option = models.ForeignKey(
|
||||
"MarketOption",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="market_won",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["status", "-created_at"]),
|
||||
models.Index(fields=["end_date"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class MarketOption(BaseModel):
|
||||
market = models.ForeignKey(Market, on_delete=models.CASCADE, related_name="options")
|
||||
text = models.CharField(max_length=255)
|
||||
|
||||
class Meta:
|
||||
ordering = ["text"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["market", "text"],
|
||||
name="unique_market_option_text",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["market"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.market.title} - {self.text}"
|
||||
|
||||
|
||||
class UserBet(BaseModel):
|
||||
user = models.ForeignKey(
|
||||
"accounts.CustomUser", on_delete=models.CASCADE, related_name="bets"
|
||||
)
|
||||
option = models.ForeignKey(
|
||||
MarketOption, on_delete=models.CASCADE, related_name="user_bets"
|
||||
)
|
||||
amount = models.PositiveIntegerField()
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "option"],
|
||||
name="unique_user_bet_per_option",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["user", "option"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} bet {self.amount} on {self.option.text}"
|
||||
|
||||
|
||||
class UserPointChange(BaseModel):
|
||||
class Reason(models.TextChoices):
|
||||
BET_PLACED = "bet_placed", "Bet Placed"
|
||||
BET_WON = "bet_won", "Bet Won"
|
||||
BET_LOST = "bet_lost", "Bet Lost"
|
||||
|
||||
user = models.ForeignKey(
|
||||
"accounts.CustomUser", on_delete=models.CASCADE, related_name="point_changes"
|
||||
)
|
||||
market = models.ForeignKey(
|
||||
Market, on_delete=models.CASCADE, related_name="point_changes"
|
||||
)
|
||||
amount = models.IntegerField()
|
||||
reason = models.CharField(max_length=20, choices=Reason.choices)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["user", "-created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} {self.reason}: {self.amount} pts on {self.market.title}"
|
||||
@@ -0,0 +1,59 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Any
|
||||
from uuid import UUID
|
||||
from ninja import Schema
|
||||
from pydantic import field_serializer, model_validator
|
||||
|
||||
|
||||
class MarketOptionSchema(Schema):
|
||||
uuid: UUID
|
||||
text: str
|
||||
total_bets: int = 0
|
||||
|
||||
@field_serializer("uuid")
|
||||
def serialize_uuid(self, value: UUID) -> str:
|
||||
return str(value)
|
||||
|
||||
|
||||
class MarketListSchema(Schema):
|
||||
uuid: UUID
|
||||
title: str
|
||||
description: str
|
||||
status: str
|
||||
end_date: datetime
|
||||
multiplier: float = 1.0
|
||||
created_at: datetime
|
||||
options: List[MarketOptionSchema]
|
||||
winning_option: Optional[MarketOptionSchema] = None
|
||||
|
||||
@field_serializer("uuid")
|
||||
def serialize_uuid(self, value: UUID) -> str:
|
||||
return str(value)
|
||||
|
||||
|
||||
class ResolveMarketSchema(Schema):
|
||||
winning_option_uuid: str
|
||||
|
||||
|
||||
class UserBetCreateSchema(Schema):
|
||||
option_uuid: str
|
||||
amount: int
|
||||
|
||||
|
||||
class UserBetSchema(Schema):
|
||||
uuid: UUID
|
||||
amount: int
|
||||
created_at: datetime
|
||||
option: MarketOptionSchema
|
||||
market: Optional[MarketListSchema] = None
|
||||
|
||||
@field_serializer("uuid")
|
||||
def serialize_uuid(self, value: UUID) -> str:
|
||||
return str(value)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def resolve_market_from_option(cls, data: Any) -> Any:
|
||||
if hasattr(data, "option") and hasattr(data.option, "market"):
|
||||
data.market = data.option.market
|
||||
return data
|
||||
@@ -0,0 +1 @@
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,8 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import render
|
||||
from django.http import HttpRequest
|
||||
|
||||
|
||||
@login_required
|
||||
def market_home(request: HttpRequest):
|
||||
return render(request, "market.html", {})
|
||||
@@ -0,0 +1,72 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from noita.services.objectives import parse_objectives_and_store
|
||||
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
|
||||
|
||||
|
||||
@admin.register(LogfileSubmission)
|
||||
class LogfileSubmissionAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"id",
|
||||
"user",
|
||||
"content_type",
|
||||
"file_size",
|
||||
"created_at",
|
||||
"processed",
|
||||
)
|
||||
list_filter = ("content_type", "processed", "created_at")
|
||||
search_fields = ("id", "user__username")
|
||||
readonly_fields = ("id", "created_at", "updated_at")
|
||||
fieldsets = (
|
||||
("Identification", {"fields": ("id",)}),
|
||||
("File Information", {"fields": ("file", "content_type", "file_size")}),
|
||||
("User", {"fields": ("user",)}),
|
||||
("Timestamps", {"fields": ("created_at", "updated_at")}),
|
||||
("Processing", {"fields": ("processed",)}),
|
||||
)
|
||||
|
||||
actions = ["validate_submission"]
|
||||
|
||||
def validate_submission(self, request, queryset):
|
||||
for logfile in queryset:
|
||||
parse_objectives_and_store(logfile)
|
||||
|
||||
self.message_user(request, f"{queryset.count()} submissions validated.")
|
||||
|
||||
|
||||
@admin.register(Objectiv)
|
||||
class ObjectivAdmin(admin.ModelAdmin):
|
||||
list_display = ("objectiv_id", "user", "first_seen_at", "get_user_objectiv_count")
|
||||
list_filter = ("objectiv_id", "user")
|
||||
search_fields = ("objectiv_id", "user__username")
|
||||
readonly_fields = ("user", "first_seen_at")
|
||||
|
||||
def get_user_objectiv_count(self, obj):
|
||||
return Objectiv.objects.filter(
|
||||
objectiv_id=obj.objectiv_id, user=obj.user
|
||||
).count()
|
||||
|
||||
get_user_objectiv_count.short_description = "Count"
|
||||
|
||||
|
||||
@admin.register(ObjectivPoint)
|
||||
class ObjectivPointAdmin(admin.ModelAdmin):
|
||||
list_display = ("objectiv_id", "display_string", "max_count", "point")
|
||||
list_filter = ("objectiv_id",)
|
||||
search_fields = ("objectiv_id", "display_string")
|
||||
fieldsets = (
|
||||
("Objective Information", {"fields": ("objectiv_id", "display_string")}),
|
||||
("Scoring", {"fields": ("max_count", "point")}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(DeathCounter)
|
||||
class DeathCounterAdmin(admin.ModelAdmin):
|
||||
list_display = ("user_id", "seed", "seen_at")
|
||||
list_filter = ("user_id", "seed")
|
||||
search_fields = ("user_id", "seed")
|
||||
|
||||
fieldsets = (
|
||||
("User Information", {"fields": ("user_id",)}),
|
||||
("Scoring", {"fields": ("seed",)}),
|
||||
)
|
||||
@@ -0,0 +1,301 @@
|
||||
from django.http import HttpRequest
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.cache import cache
|
||||
from django.db.models import (
|
||||
F,
|
||||
Case,
|
||||
When,
|
||||
Count,
|
||||
IntegerField,
|
||||
Subquery,
|
||||
OuterRef,
|
||||
)
|
||||
from ninja import Router, File
|
||||
from ninja.files import UploadedFile
|
||||
from ninja.decorators import decorate_view
|
||||
|
||||
from noita.schemas import ResultsOut, LeaderboardOut
|
||||
from noita.services.objectives import parse_objectives_and_store
|
||||
from games.decorators import require_game_enabled
|
||||
|
||||
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
|
||||
from .schemas import NoitaSubmissionOut
|
||||
|
||||
|
||||
router = Router()
|
||||
NOITA_APP_ID = 881100
|
||||
|
||||
|
||||
@router.get("results", response=ResultsOut)
|
||||
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||
def get_results(request: HttpRequest):
|
||||
cache_key = f"api:noita:results:{request.user.id}"
|
||||
cached_data = cache.get(cache_key)
|
||||
|
||||
if cached_data is not None:
|
||||
return cached_data
|
||||
"""
|
||||
Get the user's score based on their objectives.
|
||||
|
||||
Calculates points as: ObjectivPoint.point * min(max_count, count) for each objective
|
||||
Uses Django ORM annotate for efficient queryset computation.
|
||||
"""
|
||||
# Group objectives by objectiv_id and count occurrences
|
||||
user_objectives = (
|
||||
Objectiv.objects.filter(user=request.user)
|
||||
.values("objectiv_id")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
|
||||
# Fetch points from ObjectivPoint using Subquery
|
||||
user_objectives = user_objectives.annotate(
|
||||
# Get points per objective from ObjectivPoint
|
||||
points_per_objectiv=Subquery(
|
||||
ObjectivPoint.objects.filter(objectiv_id=OuterRef("objectiv_id")).values(
|
||||
"point"
|
||||
)[:1],
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
# Get max_count from ObjectivPoint
|
||||
max_objectives=Subquery(
|
||||
ObjectivPoint.objects.filter(objectiv_id=OuterRef("objectiv_id")).values(
|
||||
"max_count"
|
||||
)[:1],
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
)
|
||||
|
||||
# Handle negative max_count (means unlimited)
|
||||
user_objectives = user_objectives.annotate(
|
||||
effective_max=Case(
|
||||
When(max_objectives__lt=0, then=F("count")),
|
||||
default=F("max_objectives"),
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
)
|
||||
|
||||
# Calculate capped count and total points
|
||||
user_objectives = user_objectives.annotate(
|
||||
capped_count=Case(
|
||||
When(effective_max__lt=F("count"), then=F("effective_max")),
|
||||
default=F("count"),
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
total_points=F("points_per_objectiv") * F("capped_count"),
|
||||
)
|
||||
|
||||
# Annotate seed + first-seen-at
|
||||
user_objectives = user_objectives.annotate(
|
||||
seed=F("seed"),
|
||||
first_seen_at=F("first_seen_at"),
|
||||
)
|
||||
|
||||
# Build response with all objectives and compute total score
|
||||
total_score = 0
|
||||
with_points = {}
|
||||
for obj in ObjectivPoint.objects.all():
|
||||
with_points[obj.objectiv_id] = {
|
||||
"objectiv_id": obj.objectiv_id,
|
||||
"display_string": obj.display_string,
|
||||
"count": 0,
|
||||
"max_count": obj.max_count,
|
||||
"points_per_objectiv": obj.point,
|
||||
"total_points": 0,
|
||||
"first_seen_at": None,
|
||||
"seed": None,
|
||||
}
|
||||
|
||||
for obj in user_objectives.order_by("-total_points"):
|
||||
points = obj["total_points"] or 0
|
||||
with_points[obj["objectiv_id"]].update(
|
||||
{
|
||||
"count": obj["count"],
|
||||
"total_points": points,
|
||||
"first_seen_at": obj["first_seen_at"],
|
||||
"seed": obj["seed"],
|
||||
}
|
||||
)
|
||||
total_score += points
|
||||
|
||||
# Count deaths for the user
|
||||
deaths_count = DeathCounter.objects.filter(user=request.user).count()
|
||||
|
||||
data = {
|
||||
"total_score": total_score,
|
||||
"deaths_count": deaths_count,
|
||||
"objectives": list(with_points.values()),
|
||||
}
|
||||
|
||||
cache.set(f"api:noita:results:{request.user.id}", data, 300)
|
||||
return data
|
||||
|
||||
|
||||
@router.get("leaderboard", response=LeaderboardOut)
|
||||
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||
def get_leaderboard(request: HttpRequest):
|
||||
"""
|
||||
Get the global leaderboard for all users ranked by total score.
|
||||
|
||||
Uses Window functions to rank users by their total score in descending order.
|
||||
"""
|
||||
cache_key = "api:noita:leaderboard"
|
||||
cached_data = cache.get(cache_key)
|
||||
|
||||
if cached_data is not None:
|
||||
return cached_data
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
# Get all objectives with calculated points (grouped by objectiv_id and user)
|
||||
all_objectives = (
|
||||
Objectiv.objects.values("user", "objectiv_id")
|
||||
.annotate(count=Count("id"))
|
||||
.annotate(
|
||||
# Fetch points from ObjectivPoint using Subquery
|
||||
points_per_objectiv=Subquery(
|
||||
ObjectivPoint.objects.filter(
|
||||
objectiv_id=OuterRef("objectiv_id")
|
||||
).values("point")[:1],
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
# Get max_count from ObjectivPoint
|
||||
max_objectives=Subquery(
|
||||
ObjectivPoint.objects.filter(
|
||||
objectiv_id=OuterRef("objectiv_id")
|
||||
).values("max_count")[:1],
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
# Handle negative max_count (means unlimited)
|
||||
effective_max=Case(
|
||||
When(max_objectives__lt=0, then=F("count")),
|
||||
default=F("max_objectives"),
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
# Calculate capped count and total points
|
||||
capped_count=Case(
|
||||
When(effective_max__lt=F("count"), then=F("effective_max")),
|
||||
default=F("count"),
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
total_points=F("points_per_objectiv") * F("capped_count"),
|
||||
)
|
||||
)
|
||||
|
||||
# Build user totals by iterating through objectives
|
||||
user_totals_dict = {}
|
||||
for obj in all_objectives:
|
||||
user_id = obj["user"]
|
||||
points = obj["total_points"] or 0
|
||||
if user_id not in user_totals_dict:
|
||||
user_totals_dict[user_id] = 0
|
||||
user_totals_dict[user_id] += points
|
||||
|
||||
# Get unique users and their scores, then apply ranking
|
||||
users_with_scores = []
|
||||
for user_id, total_score in user_totals_dict.items():
|
||||
user = User.objects.get(id=user_id)
|
||||
objectives_count = (
|
||||
Objectiv.objects.filter(user_id=user_id)
|
||||
.values("objectiv_id")
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
deaths_count = DeathCounter.objects.filter(user_id=user_id).count()
|
||||
users_with_scores.append(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"user": user,
|
||||
"total_score": total_score,
|
||||
"objectives_count": objectives_count,
|
||||
"deaths_count": deaths_count,
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by score and add rank
|
||||
users_with_scores.sort(key=lambda x: x["total_score"], reverse=True)
|
||||
leaderboard = [
|
||||
{
|
||||
"rank": idx + 1,
|
||||
"username": entry["user"].username,
|
||||
"is_staff": entry["user"].is_staff,
|
||||
"total_score": entry["total_score"],
|
||||
"objectives_count": entry["objectives_count"],
|
||||
"deaths_count": entry["deaths_count"],
|
||||
}
|
||||
for idx, entry in enumerate(users_with_scores)
|
||||
]
|
||||
|
||||
data = {"leaderboard": leaderboard}
|
||||
cache.set("api:noita:leaderboard", data, 300)
|
||||
return data
|
||||
|
||||
|
||||
@router.post("submit", response={200: NoitaSubmissionOut, 400: dict})
|
||||
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||
def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
|
||||
"""
|
||||
Submit a Noita run file (log file, screenshot, or video).
|
||||
|
||||
Accepts:
|
||||
- Text files (.txt) for polylan_mod_log.txt
|
||||
- Images (.png, .jpg, .gif)
|
||||
- Videos (.mp4, .webm)
|
||||
|
||||
Max file size: 256 MB
|
||||
"""
|
||||
# Validate file type
|
||||
allowed_types = [
|
||||
"text/plain",
|
||||
"text/x-log",
|
||||
]
|
||||
|
||||
if file.content_type not in allowed_types:
|
||||
return 400, {
|
||||
"detail": f"Invalid file type: {file.content_type}. Allowed types: {', '.join(allowed_types)}"
|
||||
}
|
||||
|
||||
# Validate file size (256MB limit)
|
||||
if file.size > 256 * 1024 * 1024:
|
||||
return 400, {"detail": "File too large (max 256MB)"}
|
||||
|
||||
try:
|
||||
# Create submission
|
||||
submission = LogfileSubmission.objects.create(
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
content_type=file.content_type,
|
||||
file_size=file.size,
|
||||
)
|
||||
|
||||
# Save the file
|
||||
submission.file.save(file.name, ContentFile(file.read()), save=True)
|
||||
|
||||
try:
|
||||
parse_objectives_and_store(submission)
|
||||
submission.processed = True
|
||||
submission.save(update_fields=["processed"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Invalidate caches on successful submission
|
||||
if submission.user:
|
||||
cache.delete(f"api:noita:results:{submission.user.id}")
|
||||
cache.delete("api:noita:leaderboard")
|
||||
|
||||
return {
|
||||
"id": str(submission.id),
|
||||
"user_id": submission.user_id,
|
||||
"username": submission.user.username if submission.user else None,
|
||||
"file_size": submission.file_size,
|
||||
"content_type": submission.content_type,
|
||||
"created_at": submission.created_at,
|
||||
"processed": submission.processed,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return 500, {"detail": f"Error creating submission: {str(e)}"}
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NoitaConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "noita"
|
||||
@@ -0,0 +1,77 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from noita.models import ObjectivPoint
|
||||
from noita.services.decode import POINTS
|
||||
from noita.services.spells import ALL_PERKS, ALL_SPELLS
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Load ObjectivPoints from the POINTS dictionary in services.decode"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--clear",
|
||||
action="store_true",
|
||||
help="Clear all existing ObjectivPoints before loading",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if options["clear"]:
|
||||
ObjectivPoint.objects.all().delete()
|
||||
self.stdout.write(self.style.SUCCESS("Cleared existing ObjectivPoints"))
|
||||
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
|
||||
for objectiv_id, point_value in POINTS.items():
|
||||
# Skip special entries
|
||||
if objectiv_id in {"-", "DEBUG"}:
|
||||
continue
|
||||
|
||||
# Get display string from objectiv_id (convert to title case)
|
||||
display_string = objectiv_id.replace("_", " ").title()
|
||||
|
||||
# Create or update ObjectivPoint
|
||||
obj, created = ObjectivPoint.objects.get_or_create(
|
||||
objectiv_id=objectiv_id,
|
||||
defaults={
|
||||
"display_string": display_string,
|
||||
"point": point_value,
|
||||
"max_count": 1, # Default max_count is 1
|
||||
},
|
||||
)
|
||||
|
||||
if created:
|
||||
created_count += 1
|
||||
self.stdout.write(f"✓ Created: {objectiv_id} - {point_value} points")
|
||||
else:
|
||||
# Update if points changed
|
||||
if obj.point != point_value or obj.display_string != display_string:
|
||||
obj.point = point_value
|
||||
obj.display_string = display_string
|
||||
obj.save()
|
||||
updated_count += 1
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"↻ Updated: {objectiv_id} - {point_value} points"
|
||||
)
|
||||
)
|
||||
|
||||
for op in ObjectivPoint.objects.filter(objectiv_id__in=ALL_SPELLS):
|
||||
op.display_string = f"Spell: {op.display_string}"
|
||||
op.save()
|
||||
|
||||
for op in ObjectivPoint.objects.filter(objectiv_id__in=ALL_PERKS):
|
||||
op.display_string = f"Perk: {op.display_string}"
|
||||
op.save()
|
||||
|
||||
for op in ObjectivPoint.objects.filter(objectiv_id__startswith="BOSS_KILL_"):
|
||||
b, k, c = op.display_string.split(" ")
|
||||
op.display_string = f"Perk: {op.display_string}"
|
||||
op.display_string = f"{b} {k} (with {c} orbs)"
|
||||
op.save()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"\nCreated: {created_count}"))
|
||||
self.stdout.write(self.style.SUCCESS(f"Updated: {updated_count}"))
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Total ObjectivPoints: {ObjectivPoint.objects.count()}")
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-09 22:53
|
||||
|
||||
import django.db.models.deletion
|
||||
import noita.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,
|
||||
),
|
||||
),
|
||||
(
|
||||
"content_type",
|
||||
models.CharField(help_text="MIME type of the file", max_length=100),
|
||||
),
|
||||
(
|
||||
"file_size",
|
||||
models.PositiveIntegerField(help_text="File size in bytes"),
|
||||
),
|
||||
(
|
||||
"file",
|
||||
models.FileField(
|
||||
help_text="Uploaded file (image/gif)",
|
||||
upload_to=noita.models.submission_file_upload_path,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("processed", models.BooleanField(default=False)),
|
||||
(
|
||||
"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="noita_submissions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-09 22:55
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("noita", "0001_initial"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameModel(
|
||||
old_name="Submission",
|
||||
new_name="LogfileSubmission",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-09 23:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("noita", "0002_rename_submission_logfilesubmission"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Objectiv",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("updated_at", models.DateTimeField()),
|
||||
("objectiv_id", models.CharField(max_length=64)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-09 23:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("noita", "0003_objectiv"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="objectiv",
|
||||
name="count",
|
||||
field=models.IntegerField(default=1),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-09 23:21
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("noita", "0004_objectiv_count"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="objectiv",
|
||||
name="updated_at",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-09 23:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("noita", "0005_remove_objectiv_updated_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ObjectivPoint",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("objectiv_id", models.CharField(max_length=64, unique=True)),
|
||||
("display_string", models.CharField(max_length=255)),
|
||||
("max_count", models.IntegerField(default=1)),
|
||||
("point", models.IntegerField(default=0)),
|
||||
],
|
||||
),
|
||||
]
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-11 08:20
|
||||
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("noita", "0006_objectivpoint"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="objectiv",
|
||||
name="count",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="objectiv",
|
||||
name="first_seen_at",
|
||||
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="objectiv",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("objectiv_id", "user", "first_seen_at"),
|
||||
name="unique_objectiv_per_user_timestamp",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-11 08:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("noita", "0007_remove_objectiv_count_objectiv_first_seen_at_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="objectiv",
|
||||
name="seed",
|
||||
field=models.CharField(default="", max_length=32),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-11 08:31
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("noita", "0008_objectiv_seed"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="objectiv",
|
||||
name="submission",
|
||||
field=models.ForeignKey(
|
||||
default=1,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="noita.logfilesubmission",
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-14 23:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def fw_func(apps, _schema_editor):
|
||||
Objectiv = apps.get_model("noita", "Objectiv")
|
||||
Objectiv.objects.filter(objectiv_id="DEATH").delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("noita", "0009_objectiv_submission"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="DeathCounter",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("seed", models.CharField(max_length=32)),
|
||||
("seen_at", models.DateTimeField()),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(fw_func, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-14 23:43
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("noita", "0010_deathcounter"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="deathcounter",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
default=1,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-14 23:45
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("noita", "0011_deathcounter_user"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name="deathcounter",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("user_id", "seen_at"), name="unique_death_per_seen_at"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,84 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
import uuid
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def submission_file_upload_path(instance, filename):
|
||||
"""Generate upload path for submission files"""
|
||||
# Create path: submissions/{submission_id}/{uuid}_{filename}
|
||||
ext = filename.split(".")[-1] if "." in filename else ""
|
||||
new_filename = f"{uuid.uuid4()}_{filename}" if ext else str(uuid.uuid4())
|
||||
return f"noita-submissions/{instance.id}/{new_filename}"
|
||||
|
||||
|
||||
class LogfileSubmission(models.Model):
|
||||
"""Model representing a submission containing multiple puzzle responses"""
|
||||
|
||||
# Identification
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
content_type = models.CharField(max_length=100, help_text="MIME type of the file")
|
||||
file_size = models.PositiveIntegerField(help_text="File size in bytes")
|
||||
file = models.FileField(
|
||||
upload_to=submission_file_upload_path,
|
||||
help_text="Uploaded file (image/gif)",
|
||||
)
|
||||
|
||||
# User information (optional for anonymous submissions)
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="User who made the submission (null for anonymous)",
|
||||
related_name="noita_submissions",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
processed = models.BooleanField(default=False)
|
||||
|
||||
|
||||
class Objectiv(models.Model):
|
||||
objectiv_id = models.CharField(max_length=64)
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
first_seen_at = models.DateTimeField(default=timezone.now)
|
||||
seed = models.CharField(max_length=32)
|
||||
submission = models.ForeignKey("LogfileSubmission", on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["objectiv_id", "user", "first_seen_at"],
|
||||
name="unique_objectiv_per_user_timestamp",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class ObjectivPoint(models.Model):
|
||||
objectiv_id = models.CharField(max_length=64, unique=True)
|
||||
display_string = models.CharField(max_length=255)
|
||||
max_count = models.IntegerField(default=1)
|
||||
point = models.IntegerField(default=0)
|
||||
|
||||
|
||||
class DeathCounter(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
seed = models.CharField(max_length=32)
|
||||
seen_at = models.DateTimeField()
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user_id", "seen_at"],
|
||||
name="unique_death_per_seen_at",
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class NoitaSubmissionOut(Schema):
|
||||
id: str
|
||||
user_id: Optional[int]
|
||||
username: Optional[str]
|
||||
file_size: int
|
||||
content_type: str
|
||||
created_at: datetime
|
||||
processed: bool
|
||||
|
||||
|
||||
class ObjectivResultOut(Schema):
|
||||
objectiv_id: str
|
||||
display_string: str
|
||||
first_seen_at: datetime | None
|
||||
count: int
|
||||
max_count: int
|
||||
seed: str | None
|
||||
points_per_objectiv: int
|
||||
total_points: int | None
|
||||
|
||||
|
||||
class ResultsOut(Schema):
|
||||
total_score: int
|
||||
deaths_count: int
|
||||
objectives: list[ObjectivResultOut]
|
||||
|
||||
|
||||
class LeaderboardEntryOut(Schema):
|
||||
rank: int
|
||||
username: str
|
||||
is_staff: bool
|
||||
total_score: int
|
||||
objectives_count: int
|
||||
deaths_count: int
|
||||
|
||||
|
||||
class LeaderboardOut(Schema):
|
||||
leaderboard: list[LeaderboardEntryOut]
|
||||
@@ -0,0 +1,334 @@
|
||||
"""
|
||||
Decode a polylan_mod_log.txt.
|
||||
|
||||
Hash format: sha1(seed|timestamp|id) — timestamp is bound into the hash so
|
||||
it cannot be altered without breaking the signature.
|
||||
|
||||
Seed-less entries (INIT, DEBUG, mod checks) use sha1(id) with no seed or
|
||||
timestamp — those are resolved via a static lookup.
|
||||
|
||||
Usage:
|
||||
python decode_log.py [path/to/polylan_mod_log.txt]
|
||||
|
||||
Default path: ~/.local/share/Steam/steamapps/common/Noita/polylan_mod_log.txt
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from functools import cache
|
||||
from noita.services.spells import ALL_SPELLS, ALL_PERKS
|
||||
|
||||
|
||||
SEED_POOL = [
|
||||
# General good seeds
|
||||
3154823,
|
||||
3718311,
|
||||
10064758,
|
||||
123156801,
|
||||
1024089369,
|
||||
1026967166,
|
||||
# Pacifist seeds
|
||||
177795258,
|
||||
520542929,
|
||||
10600249,
|
||||
25300740,
|
||||
21875589,
|
||||
24085389,
|
||||
59775105,
|
||||
44190726,
|
||||
1039649471,
|
||||
1072607354,
|
||||
# Perk combo seeds
|
||||
839747651,
|
||||
839844768,
|
||||
840909713,
|
||||
839959129,
|
||||
840016192,
|
||||
840039886,
|
||||
840398606,
|
||||
840439045,
|
||||
840457463,
|
||||
840492754,
|
||||
840507802,
|
||||
840513742,
|
||||
840542079,
|
||||
840574169,
|
||||
840610974,
|
||||
840626894,
|
||||
840872436,
|
||||
840894605,
|
||||
841221188,
|
||||
]
|
||||
|
||||
DEATH_PENALTY = 1
|
||||
|
||||
# All scoreable events: base 1 pt for every spell and perk, overrides below.
|
||||
POINTS = {
|
||||
# ── Spells ───────────────────────────────────────────────────────────────
|
||||
**{sid: 3 for sid in ALL_SPELLS},
|
||||
"ADD_TRIGGER": 10,
|
||||
"ADD_TIMER": 10,
|
||||
"ADD_DEATH_TRIGGER": 10,
|
||||
"NOLLA": 10,
|
||||
"CHAOTIC_TRANSMUTATION": 10,
|
||||
"DUPLICATE": 5,
|
||||
"BURST_2": 10,
|
||||
"BURST_3": 15,
|
||||
"BURST_4": 20,
|
||||
"BURST_8": 20,
|
||||
"BURST_X": 20,
|
||||
"HEAL_BULLET": 5,
|
||||
"ANTIHEAL": 5,
|
||||
"NUKE": 5,
|
||||
"NUKE_GIGA": 5,
|
||||
"TELEPORT_PROJECTILE": 5,
|
||||
"TELEPORT_PROJECTILE_SHORT": 5,
|
||||
"TOUCH_BLOOD": 100,
|
||||
"TOUCH_GOLD": 100,
|
||||
"TOUCH_PISS": 100,
|
||||
"TOUCH_GRASS": 100,
|
||||
"TOUCH_OIL": 100,
|
||||
"TOUCH_SMOKE": 100,
|
||||
"TOUCH_ALCOHOL": 100,
|
||||
"TOUCH_WATER": 100,
|
||||
"SPELLS_TO_POWER": 10,
|
||||
"ALL_SPELLS": 100,
|
||||
"DIVIDE_2": 10,
|
||||
"DIVIDE_3": 15,
|
||||
"DIVIDE_4": 20,
|
||||
"DIVIDE_10": 50,
|
||||
"ALPHA": 50,
|
||||
"GAMMA": 50,
|
||||
"MU": 50,
|
||||
"OMEGA": 50,
|
||||
"PHI": 50,
|
||||
"SIGMA": 50,
|
||||
"TAU": 50,
|
||||
"ZETA": 50,
|
||||
"DISC_BULLET_BIGGER": 20,
|
||||
"SUMMON_WANDGHOST": 50,
|
||||
"ALL_BLACKHOLES": 20,
|
||||
"ALL_DEATHCROSSES": 20,
|
||||
"ALL_ROCKETS": 20,
|
||||
"ALL_NUKES": 20,
|
||||
"ALL_DISCS": 20,
|
||||
# ── Perks ─────────────────────────────────────────────────────────────────
|
||||
**{pid: 15 for pid in ALL_PERKS},
|
||||
"PROTECTION_FIRE": 30,
|
||||
"PROTECTION_RADIOACTIVITY": 30,
|
||||
"PROTECTION_EXPLOSION": 30,
|
||||
"PROTECTION_MELEE": 30,
|
||||
"PROTECTION_ELECTRICITY": 30,
|
||||
# ── Spell combos ─────────────────────────────────────────────────────────
|
||||
"PING_PONG_DRILL": 40,
|
||||
"HEAVY_SHOT_DISC": 40,
|
||||
"TWO_ARC_MODIFIERS": 50,
|
||||
"TWO_TRAIL_MODIFIERS": 50,
|
||||
# ── Perk combos ──────────────────────────────────────────────────────────
|
||||
"CRIMSON_ALCHEMIST": 50,
|
||||
"GREEDY_GOBLIN_KING": 50,
|
||||
"STORM_TOUCHED_ASCENDANT": 50,
|
||||
"ARCHMAGE_OF_CONTROL": 50,
|
||||
"HAUNTED_MAGE": 50,
|
||||
"GLASS_CANNON_MESSIAH": 50,
|
||||
"INFINITE_ENGINE": 50,
|
||||
"PERFECT_ACCURACY_LOOP": 50,
|
||||
"HOMING_DEATH_SWARM": 50,
|
||||
"UNTOUCHABLE_FIELD": 50,
|
||||
"ELECTRIC_SUSTAIN_LOOP": 50,
|
||||
"IMMORTAL_LEECH_CORE": 50,
|
||||
"PROJECTILE_OVERLOAD": 50,
|
||||
"CLOSE_RANGE_DEATH_MACHINE": 50,
|
||||
"CRITICAL_MASS": 50,
|
||||
"HOLY_MOUNTAIN_ABUSER": 50,
|
||||
"DEFLECTOR_MATRIX": 50,
|
||||
"STORMBORNE_LEVITATOR": 50,
|
||||
# ── Objectives ───────────────────────────────────────────────────────────
|
||||
"HP_200": 50,
|
||||
"HP_500": 50,
|
||||
"HP_2000": 100,
|
||||
"HP_5000": 500,
|
||||
"GOLD_1000": 5,
|
||||
"GOLD_10000": 50,
|
||||
"GOLD_100000": 100,
|
||||
"GOLD_1000000": 200,
|
||||
**{f"ORB_{i}": 25 for i in range(34)},
|
||||
**{f"BOSS_KILL_{i}": 75 * i for i in range(34)},
|
||||
"WAND_MANA_500": 10,
|
||||
"WAND_MANA_1000": 10,
|
||||
"WAND_MANA_1500": 10,
|
||||
"WAND_CAPACITY_10": 10,
|
||||
"WAND_CAPACITY_20": 10,
|
||||
"BOSS_KILL": 100,
|
||||
"CRIMSON_ALCHEMIST-BOSS_KILL": 100,
|
||||
"GREEDY_GOBLIN_KING-BOSS_KILL": 100,
|
||||
"STORM_TOUCHED_ASCENDANT-BOSS_KILL": 100,
|
||||
"ARCHMAGE_OF_CONTROL-BOSS_KILL": 100,
|
||||
"HAUNTED_MAGE-BOSS_KILL": 100,
|
||||
"GLASS_CANNON_MESSIAH-BOSS_KILL": 100,
|
||||
"INFINITE_ENGINE-BOSS_KILL": 100,
|
||||
"PERFECT_ACCURACY_LOOP-BOSS_KILL": 100,
|
||||
"HOMING_DEATH_SWARM-BOSS_KILL": 100,
|
||||
"UNTOUCHABLE_FIELD-BOSS_KILL": 100,
|
||||
"ELECTRIC_SUSTAIN_LOOP-BOSS_KILL": 100,
|
||||
"IMMORTAL_LEECH_CORE-BOSS_KILL": 100,
|
||||
"PROJECTILE_OVERLOAD-BOSS_KILL": 100,
|
||||
"CLOSE_RANGE_DEATH_MACHINE-BOSS_KILL": 100,
|
||||
"CRITICAL_MASS-BOSS_KILL": 100,
|
||||
"HOLY_MOUNTAIN_ABUSER-BOSS_KILL": 100,
|
||||
"DEFLECTOR_MATRIX-BOSS_KILL": 100,
|
||||
"STORMBORNE_LEVITATOR-BOSS_KILL": 100,
|
||||
}
|
||||
|
||||
# Perk-combo IDs — used to award a per-combo boss-kill bonus.
|
||||
PERK_COMBO_IDS = {
|
||||
"CRIMSON_ALCHEMIST",
|
||||
"GREEDY_GOBLIN_KING",
|
||||
"STORM_TOUCHED_ASCENDANT",
|
||||
"ARCHMAGE_OF_CONTROL",
|
||||
"HAUNTED_MAGE",
|
||||
"GLASS_CANNON_MESSIAH",
|
||||
"INFINITE_ENGINE",
|
||||
"PERFECT_ACCURACY_LOOP",
|
||||
"HOMING_DEATH_SWARM",
|
||||
"UNTOUCHABLE_FIELD",
|
||||
"ELECTRIC_SUSTAIN_LOOP",
|
||||
"IMMORTAL_LEECH_CORE",
|
||||
"PROJECTILE_OVERLOAD",
|
||||
"CLOSE_RANGE_DEATH_MACHINE",
|
||||
"CRITICAL_MASS",
|
||||
"HOLY_MOUNTAIN_ABUSER",
|
||||
"DEFLECTOR_MATRIX",
|
||||
"STORMBORNE_LEVITATOR",
|
||||
}
|
||||
|
||||
BOSS_KILL_COMBO_BONUS = 100 # default bonus per active perk combo on boss kill
|
||||
|
||||
# IDs tried with the seed+timestamp scheme.
|
||||
_ALL_IDS = list(POINTS.keys()) + ["DEATH"]
|
||||
|
||||
# Seed-less hashes (sha1(id) only, no seed, no timestamp).
|
||||
_STATIC_LOOKUP = {
|
||||
hashlib.sha1(k.encode()).hexdigest(): k
|
||||
for k in ["-", "DEBUG", "polylan-mod", "cheatgui", "alchemy_recipes_display"]
|
||||
}
|
||||
|
||||
|
||||
def _sha1(text: str) -> str:
|
||||
return hashlib.sha1(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@cache
|
||||
def resolve(
|
||||
hash_val: str, ts: str, preferred_seed: int | None = None
|
||||
) -> tuple[str | None, int | None]:
|
||||
"""Return (name, seed) or (None, None). preferred_seed is tried first."""
|
||||
if hash_val in _STATIC_LOOKUP:
|
||||
return _STATIC_LOOKUP[hash_val], None
|
||||
|
||||
seeds = (
|
||||
[preferred_seed] + [s for s in SEED_POOL if s != preferred_seed]
|
||||
if preferred_seed is not None
|
||||
else SEED_POOL
|
||||
)
|
||||
|
||||
for seed in seeds:
|
||||
prefix = f"{seed}|{ts}|"
|
||||
for name in _ALL_IDS:
|
||||
if _sha1(prefix + name) == hash_val:
|
||||
return name, seed
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def parse_log(file) -> list:
|
||||
entries = []
|
||||
for il, line in enumerate(file.split("\n")):
|
||||
m = re.match(r"\[(.+?)\] ([0-9a-f]{40})", line.rstrip())
|
||||
if m:
|
||||
entries.append({"ts": m.group(1), "hash": m.group(2)})
|
||||
continue
|
||||
|
||||
print(f"Unable to parse line number {il:>4}: {line.strip()}??")
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def decode(file) -> None:
|
||||
entries = parse_log(file)
|
||||
|
||||
if not entries:
|
||||
print("No entries found in log.")
|
||||
return
|
||||
|
||||
seen = set()
|
||||
total = 0
|
||||
deaths = 0
|
||||
have_init = False
|
||||
have_debug = False
|
||||
known_seed = None # cached once first seeded entry is resolved
|
||||
current_run_perk_combos = set() # perk combos seen since the last "-" entry
|
||||
|
||||
for entry in entries:
|
||||
name, seed = resolve(entry["hash"], entry["ts"], known_seed)
|
||||
|
||||
if name is None:
|
||||
print(f" ???? (unresolved) {entry['hash']} ts: {entry['ts']}")
|
||||
continue
|
||||
|
||||
if seed is not None and known_seed is None:
|
||||
known_seed = seed
|
||||
|
||||
if name == "-":
|
||||
have_init = True
|
||||
current_run_perk_combos = set()
|
||||
continue
|
||||
|
||||
if name == "DEBUG":
|
||||
have_debug = True
|
||||
continue
|
||||
|
||||
if name == "DEATH":
|
||||
deaths += 1
|
||||
continue
|
||||
|
||||
# skip mod-presence entries
|
||||
if name not in POINTS:
|
||||
continue
|
||||
|
||||
if name in seen:
|
||||
continue
|
||||
|
||||
if name not in {"BOSS_KILL"}:
|
||||
seen.add(name)
|
||||
|
||||
if name in PERK_COMBO_IDS:
|
||||
current_run_perk_combos.add(name)
|
||||
|
||||
pts = POINTS.get(name, 0)
|
||||
if pts:
|
||||
print(f" +{pts:>4} {name:<40} first seen: {entry['ts']}")
|
||||
total += pts
|
||||
|
||||
if name == "BOSS_KILL":
|
||||
for combo_id in sorted(current_run_perk_combos):
|
||||
bonus_key = f"{combo_id}-BOSS_KILL"
|
||||
if bonus_key not in seen:
|
||||
seen.add(bonus_key)
|
||||
bonus_pts = POINTS.get(bonus_key, BOSS_KILL_COMBO_BONUS)
|
||||
print(f" +{bonus_pts:>4} {bonus_key:<40} combo bonus")
|
||||
total += bonus_pts
|
||||
|
||||
death_deduction = deaths * DEATH_PENALTY
|
||||
if deaths:
|
||||
print(
|
||||
f" -{death_deduction:>4} ({deaths} death{'s' if deaths > 1 else ''} × {DEATH_PENALTY} pts)"
|
||||
)
|
||||
|
||||
if have_init:
|
||||
print(
|
||||
f"\nTotal: {total - death_deduction} pts ({total} earned − {death_deduction} penalty)"
|
||||
)
|
||||
|
||||
if have_debug:
|
||||
print("Note: DEBUG mode was active during at least one session.")
|
||||
@@ -0,0 +1,54 @@
|
||||
from noita.models import LogfileSubmission, Objectiv, DeathCounter
|
||||
from noita.services.decode import parse_log, resolve
|
||||
|
||||
|
||||
def parse_objectives_from_logfile(
|
||||
logfile: LogfileSubmission,
|
||||
) -> list[tuple[str, str, str]]:
|
||||
"""Parse a log file, and output a count for each ID."""
|
||||
file_data = logfile.file.read().decode()
|
||||
|
||||
entries: list[tuple[str, str, str]] = []
|
||||
for entry in parse_log(file_data):
|
||||
idx, seed = resolve(entry["hash"], entry["ts"])
|
||||
|
||||
if idx and seed:
|
||||
entries.append((idx, str(seed), entry["ts"]))
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
|
||||
"""Parse a logfile and store output."""
|
||||
|
||||
if not logfile.user:
|
||||
return
|
||||
|
||||
objectives = []
|
||||
deaths = []
|
||||
for idx, seed, ts in parse_objectives_from_logfile(logfile):
|
||||
if idx in {"-", "DEBUG", "polylan-mod"}:
|
||||
continue
|
||||
|
||||
if idx == "DEATH":
|
||||
deaths.append(DeathCounter(user=logfile.user, seed=seed, seen_at=ts))
|
||||
continue
|
||||
|
||||
objectives.append(
|
||||
Objectiv(
|
||||
objectiv_id=idx,
|
||||
user=logfile.user,
|
||||
first_seen_at=ts,
|
||||
seed=seed,
|
||||
submission=logfile,
|
||||
)
|
||||
)
|
||||
|
||||
Objectiv.objects.bulk_create(
|
||||
objectives,
|
||||
update_conflicts=True,
|
||||
update_fields=["seed", "submission"],
|
||||
unique_fields=["objectiv_id", "user", "first_seen_at"],
|
||||
)
|
||||
|
||||
DeathCounter.objects.bulk_create(deaths, ignore_conflicts=True)
|
||||
@@ -0,0 +1,535 @@
|
||||
ALL_SPELLS = [
|
||||
"FUNKY_SPELL",
|
||||
"ACIDSHOT",
|
||||
"BLACK_HOLE",
|
||||
"BLACK_HOLE_DEATH_TRIGGER",
|
||||
"BOMB",
|
||||
"BOMB_CART",
|
||||
"BUBBLESHOT",
|
||||
"BUBBLESHOT_TRIGGER",
|
||||
"AIR_BULLET",
|
||||
"CHAIN_BOLT",
|
||||
"CHAINSAW",
|
||||
"CURSED_ORB",
|
||||
"ANTIHEAL",
|
||||
"DEATH_CROSS",
|
||||
"DEATH_CROSS_BIG",
|
||||
"LASER_EMITTER_FOUR",
|
||||
"POWERDIGGER",
|
||||
"DIGGER",
|
||||
"PIPE_BOMB",
|
||||
"PIPE_BOMB_DEATH_TRIGGER",
|
||||
"GRENADE_LARGE",
|
||||
"DYNAMITE",
|
||||
"CRUMBLING_EARTH",
|
||||
"TENTACLE_PORTAL",
|
||||
"SLOW_BULLET",
|
||||
"SLOW_BULLET_TRIGGER",
|
||||
"SLOW_BULLET_TIMER",
|
||||
"EXPANDING_ORB",
|
||||
"FIREBALL",
|
||||
"GRENADE",
|
||||
"GRENADE_TRIGGER",
|
||||
"GRENADE_TIER_2",
|
||||
"GRENADE_TIER_3",
|
||||
"GRENADE_ANTI",
|
||||
"FIREBOMB",
|
||||
"FIREWORK",
|
||||
"FLAMETHROWER",
|
||||
"GLITTER_BOMB",
|
||||
"LANCE",
|
||||
"GLUE_SHOT",
|
||||
"HEAL_BULLET",
|
||||
"LANCE_HOLY",
|
||||
"BOMB_HOLY",
|
||||
"BOMB_HOLY_GIGA",
|
||||
"HOOK",
|
||||
"ICEBALL",
|
||||
"LASER",
|
||||
"LIGHTNING",
|
||||
"THUNDERBALL",
|
||||
"BALL_LIGHTNING",
|
||||
"LUMINOUS_DRILL",
|
||||
"LASER_LUMINOUS_DRILL",
|
||||
"BULLET",
|
||||
"BULLET_TRIGGER",
|
||||
"BULLET_TIMER",
|
||||
"HEAVY_BULLET",
|
||||
"HEAVY_BULLET_TRIGGER",
|
||||
"HEAVY_BULLET_TIMER",
|
||||
"MAGIC_SHIELD",
|
||||
"BIG_MAGIC_SHIELD",
|
||||
"ROCKET",
|
||||
"ROCKET_TIER_2",
|
||||
"ROCKET_TIER_3",
|
||||
"METEOR",
|
||||
"MIST_BLOOD",
|
||||
"MIST_ALCOHOL",
|
||||
"MIST_SLIME",
|
||||
"MIST_RADIOACTIVE",
|
||||
"BUCKSHOT",
|
||||
"MEGALASER",
|
||||
"EXPLODING_DUCKS",
|
||||
"FREEZING_GAZE",
|
||||
"INFESTATION",
|
||||
"NUKE",
|
||||
"NUKE_GIGA",
|
||||
"DARKFLAME",
|
||||
"GLOWING_BOLT",
|
||||
"LASER_EMITTER",
|
||||
"LASER_EMITTER_CUTTER",
|
||||
"POLLEN",
|
||||
"SPORE_POD",
|
||||
"PROPANE_TANK",
|
||||
"RANDOM_PROJECTILE",
|
||||
"SUMMON_ROCK",
|
||||
"DISC_BULLET",
|
||||
"DISC_BULLET_BIG",
|
||||
"DISC_BULLET_BIGGER",
|
||||
"SLIMEBALL",
|
||||
"LIGHT_BULLET",
|
||||
"LIGHT_BULLET_TRIGGER",
|
||||
"LIGHT_BULLET_TRIGGER_2",
|
||||
"LIGHT_BULLET_TIMER",
|
||||
"RUBBER_BALL",
|
||||
"ARROW",
|
||||
"BOUNCY_ORB",
|
||||
"BOUNCY_ORB_TIMER",
|
||||
"SPIRAL_SHOT",
|
||||
"SPITTER",
|
||||
"SPITTER_TIMER",
|
||||
"SPITTER_TIER_2",
|
||||
"SPITTER_TIER_2_TIMER",
|
||||
"SPITTER_TIER_3",
|
||||
"SPITTER_TIER_3_TIMER",
|
||||
"EXPLODING_DEER",
|
||||
"SUMMON_EGG",
|
||||
"TNTBOX",
|
||||
"TNTBOX_BIG",
|
||||
"FISH",
|
||||
"SUMMON_HOLLOW_EGG",
|
||||
"MISSILE",
|
||||
"PEBBLE",
|
||||
"TENTACLE",
|
||||
"TENTACLE_TIMER",
|
||||
"TELEPORT_PROJECTILE_CLOSER",
|
||||
"TELEPORT_PROJECTILE_STATIC",
|
||||
"SWAPPER_PROJECTILE",
|
||||
"TELEPORT_PROJECTILE",
|
||||
"TELEPORT_PROJECTILE_SHORT",
|
||||
"MINE",
|
||||
"MINE_DEATH_TRIGGER",
|
||||
"WHITE_HOLE",
|
||||
"WORM_SHOT",
|
||||
"WALL_HORIZONTAL",
|
||||
"WALL_VERTICAL",
|
||||
"WALL_SQUARE",
|
||||
"REGENERATION_FIELD",
|
||||
"FREEZE_FIELD",
|
||||
"LEVITATION_FIELD",
|
||||
"TELEPORTATION_FIELD",
|
||||
"BERSERK_FIELD",
|
||||
"SHIELD_FIELD",
|
||||
"ELECTROCUTION_FIELD",
|
||||
"POLYMORPH_FIELD",
|
||||
"CHAOS_POLYMORPH_FIELD",
|
||||
"CLOUD_WATER",
|
||||
"CLOUD_OIL",
|
||||
"CLOUD_BLOOD",
|
||||
"CLOUD_ACID",
|
||||
"CLOUD_THUNDER",
|
||||
"DESTRUCTION",
|
||||
"BOMB_DETONATOR",
|
||||
"PURPLE_EXPLOSION_FIELD",
|
||||
"WORM_RAIN",
|
||||
"METEOR_RAIN",
|
||||
"SWARM_FLY",
|
||||
"SWARM_FIREBUG",
|
||||
"SWARM_WASP",
|
||||
"DELAYED_SPELL",
|
||||
"MASS_POLYMORPH",
|
||||
"PROJECTILE_THUNDER_FIELD",
|
||||
"PROJECTILE_GRAVITY_FIELD",
|
||||
"PROJECTILE_TRANSMUTATION_FIELD",
|
||||
"RANDOM_STATIC_PROJECTILE",
|
||||
"WHITE_HOLE_BIG",
|
||||
"BLACK_HOLE_BIG",
|
||||
"BLACK_HOLE_GIGA",
|
||||
"THUNDER_BLAST",
|
||||
"FIRE_BLAST",
|
||||
"EXPLOSION_LIGHT",
|
||||
"POISON_BLAST",
|
||||
"EXPLOSION",
|
||||
"ALCOHOL_BLAST",
|
||||
"WHITE_HOLE_GIGA",
|
||||
"FRIEND_FLY",
|
||||
"VACUUM_POWDER",
|
||||
"VACUUM_LIQUID",
|
||||
"VACUUM_ENTITIES",
|
||||
"CLUSTERMOD",
|
||||
"MANA_REDUCE",
|
||||
"ARC_POISON",
|
||||
"ARC_FIRE",
|
||||
"ARC_GUNPOWDER",
|
||||
"ARC_ELECTRIC",
|
||||
"ROCKET_DOWNWARDS",
|
||||
"ROCKET_OCTAGON",
|
||||
"BOUNCE",
|
||||
"BOUNCE_SPARK",
|
||||
"BOUNCE_LASER",
|
||||
"REMOVE_BOUNCE",
|
||||
"BOUNCE_SMALL_EXPLOSION",
|
||||
"BOUNCE_HOLE",
|
||||
"BOUNCE_EXPLOSION",
|
||||
"BOUNCE_LARPA",
|
||||
"BOUNCE_LIGHTNING",
|
||||
"BOUNCE_LASER_EMITTER",
|
||||
"EXPLOSION_TINY",
|
||||
"HITFX_CRITICAL_BLOOD",
|
||||
"HITFX_CRITICAL_OIL",
|
||||
"HITFX_CRITICAL_WATER",
|
||||
"HITFX_BURNING_CRITICAL_HIT",
|
||||
"CRITICAL_HIT",
|
||||
"AREA_DAMAGE",
|
||||
"BLOODLUST",
|
||||
"DAMAGE",
|
||||
"DAMAGE_RANDOM",
|
||||
"DAMAGE_FOREVER",
|
||||
"ZERO_DAMAGE",
|
||||
"HEAVY_SHOT",
|
||||
"LIGHT_SHOT",
|
||||
"CRUMBLING_EARTH_PROJECTILE",
|
||||
"FREEZE",
|
||||
"ELECTRIC_CHARGE",
|
||||
"HITFX_EXPLOSION_ALCOHOL",
|
||||
"HITFX_EXPLOSION_ALCOHOL_GIGA",
|
||||
"HITFX_EXPLOSION_SLIME",
|
||||
"HITFX_EXPLOSION_SLIME_GIGA",
|
||||
"HITFX_TOXIC_CHARM",
|
||||
"COLOUR_RED",
|
||||
"COLOUR_ORANGE",
|
||||
"COLOUR_YELLOW",
|
||||
"COLOUR_GREEN",
|
||||
"COLOUR_BLUE",
|
||||
"COLOUR_PURPLE",
|
||||
"COLOUR_RAINBOW",
|
||||
"COLOUR_INVIS",
|
||||
"HEAVY_SPREAD",
|
||||
"KNOCKBACK",
|
||||
"LARPA_CHAOS_2",
|
||||
"LARPA_CHAOS",
|
||||
"LARPA_DOWNWARDS",
|
||||
"LARPA_DEATH",
|
||||
"LARPA_UPWARDS",
|
||||
"LIFETIME_DOWN",
|
||||
"LIFETIME",
|
||||
"CHAIN_SHOT",
|
||||
"NOLLA",
|
||||
"LIGHT",
|
||||
"NECROMANCY",
|
||||
"ORBIT_DISCS",
|
||||
"ORBIT_NUKES",
|
||||
"ORBIT_FIREBALLS",
|
||||
"ORBIT_LARPA",
|
||||
"ORBIT_LASERS",
|
||||
"LINE_ARC",
|
||||
"HORIZONTAL_ARC",
|
||||
"GRAVITY",
|
||||
"GRAVITY_ANTI",
|
||||
"FLY_UPWARDS",
|
||||
"FLY_DOWNWARDS",
|
||||
"ORBIT_SHOT",
|
||||
"TRUE_ORBIT",
|
||||
"SPIRALING_SHOT",
|
||||
"PINGPONG_PATH",
|
||||
"PHASING_ARC",
|
||||
"CHAOTIC_ARC",
|
||||
"SINEWAVE",
|
||||
"HOMING_WAND",
|
||||
"HOMING_CURSOR",
|
||||
"HOMING_SHORT",
|
||||
"AUTOAIM",
|
||||
"HOMING",
|
||||
"HOMING_ROTATE",
|
||||
"HOMING_SHOOTER",
|
||||
"ANTI_HOMING",
|
||||
"HOMING_ACCELERATING",
|
||||
"HOMING_AREA",
|
||||
"FIREBALL_RAY_ENEMY",
|
||||
"GRAVITY_FIELD_ENEMY",
|
||||
"TENTACLE_RAY_ENEMY",
|
||||
"LIGHTNING_RAY_ENEMY",
|
||||
"HITFX_PETRIFY",
|
||||
"PIERCING_SHOT",
|
||||
"LASER_EMITTER_WIDER",
|
||||
"QUANTUM_SPLIT",
|
||||
"RANDOM_EXPLOSION",
|
||||
"RANDOM_MODIFIER",
|
||||
"RECOIL",
|
||||
"RECOIL_DAMPER",
|
||||
"RECHARGE",
|
||||
"SPREAD_REDUCE",
|
||||
"EXPLOSION_REMOVE",
|
||||
"SLOW_BUT_STEADY",
|
||||
"ENERGY_SHIELD_SHOT",
|
||||
"SPEED",
|
||||
"DECELERATING_SHOT",
|
||||
"ACCELERATING_SHOT",
|
||||
"FIZZLE",
|
||||
"FLOATING_ARC",
|
||||
"AVOIDING_ARC",
|
||||
"CLIPPING_SHOT",
|
||||
"UNSTABLE_GUNPOWDER",
|
||||
"MATTER_EATER",
|
||||
"EXPLOSIVE_PROJECTILE",
|
||||
"FIREBALL_RAY_LINE",
|
||||
"FIREBALL_RAY",
|
||||
"LIGHTNING_RAY",
|
||||
"TENTACLE_RAY",
|
||||
"LASER_EMITTER_RAY",
|
||||
"SPELLS_TO_POWER",
|
||||
"ESSENCE_TO_POWER",
|
||||
"ACID_TRAIL",
|
||||
"FIRE_TRAIL",
|
||||
"GUNPOWDER_TRAIL",
|
||||
"OIL_TRAIL",
|
||||
"POISON_TRAIL",
|
||||
"RAINBOW_TRAIL",
|
||||
"WATER_TRAIL",
|
||||
"BURN_TRAIL",
|
||||
"WATER_TO_POISON",
|
||||
"BLOOD_TO_ACID",
|
||||
"LAVA_TO_BLOOD",
|
||||
"LIQUID_TO_EXPLOSION",
|
||||
"TOXIC_TO_ACID",
|
||||
"STATIC_TO_SAND",
|
||||
"TRANSMUTATION",
|
||||
"CURSE_WITHER_ELECTRICITY",
|
||||
"CURSE_WITHER_EXPLOSION",
|
||||
"CURSE_WITHER_MELEE",
|
||||
"CURSE_WITHER_PROJECTILE",
|
||||
"CURSE",
|
||||
"BURST_2",
|
||||
"BURST_3",
|
||||
"BURST_4",
|
||||
"BURST_8",
|
||||
"BURST_X",
|
||||
"SCATTER_2",
|
||||
"SCATTER_3",
|
||||
"SCATTER_4",
|
||||
"I_SHAPE",
|
||||
"T_SHAPE",
|
||||
"PENTAGRAM_SHAPE",
|
||||
"CIRCLE_SHAPE",
|
||||
"Y_SHAPE",
|
||||
"W_SHAPE",
|
||||
"TOUCH_PISS",
|
||||
"TOUCH_GRASS",
|
||||
"SOILBALL",
|
||||
"CIRCLE_FIRE",
|
||||
"CIRCLE_ACID",
|
||||
"CIRCLE_OIL",
|
||||
"CIRCLE_WATER",
|
||||
"MATERIAL_BLOOD",
|
||||
"MATERIAL_CEMENT",
|
||||
"MATERIAL_OIL",
|
||||
"MATERIAL_ACID",
|
||||
"MATERIAL_WATER",
|
||||
"SEA_LAVA",
|
||||
"SEA_ALCOHOL",
|
||||
"SEA_OIL",
|
||||
"SEA_WATER",
|
||||
"SEA_ACID",
|
||||
"SEA_ACID_GAS",
|
||||
"SEA_SWAMP",
|
||||
"SEA_MIMIC",
|
||||
"TOUCH_BLOOD",
|
||||
"TOUCH_GOLD",
|
||||
"TOUCH_OIL",
|
||||
"TOUCH_SMOKE",
|
||||
"TOUCH_ALCOHOL",
|
||||
"TOUCH_WATER",
|
||||
"X_RAY",
|
||||
"BLOOD_MAGIC",
|
||||
"CASTER_CAST",
|
||||
"I_SHOT",
|
||||
"Y_SHOT",
|
||||
"T_SHOT",
|
||||
"W_SHOT",
|
||||
"QUAD_SHOT",
|
||||
"PENTA_SHOT",
|
||||
"HEXA_SHOT",
|
||||
"SUPER_TELEPORT_CAST",
|
||||
"TELEPORT_CAST",
|
||||
"LONG_DISTANCE_CAST",
|
||||
"ALL_ACID",
|
||||
"ALL_BLACKHOLES",
|
||||
"ALL_DEATHCROSSES",
|
||||
"ALL_ROCKETS",
|
||||
"ALL_NUKES",
|
||||
"ALL_DISCS",
|
||||
"SUMMON_WANDGHOST",
|
||||
"TEMPORARY_PLATFORM",
|
||||
"TEMPORARY_WALL",
|
||||
"MONEY_MAGIC",
|
||||
"BLOOD_TO_POWER",
|
||||
"RESET",
|
||||
"ENERGY_SHIELD",
|
||||
"ENERGY_SHIELD_SECTOR",
|
||||
"TINY_GHOST",
|
||||
"TORCH",
|
||||
"TORCH_ELECTRIC",
|
||||
"ADD_TRIGGER",
|
||||
"ADD_TIMER",
|
||||
"ADD_DEATH_TRIGGER",
|
||||
"CESSATION",
|
||||
"DIVIDE_2",
|
||||
"DIVIDE_3",
|
||||
"DIVIDE_4",
|
||||
"DIVIDE_10",
|
||||
"OMEGA",
|
||||
"ZETA",
|
||||
"TAU",
|
||||
"SIGMA",
|
||||
"PHI",
|
||||
"MU",
|
||||
"ALPHA",
|
||||
"GAMMA",
|
||||
"KANTELE_A",
|
||||
"KANTELE_D",
|
||||
"KANTELE_DIS",
|
||||
"KANTELE_E",
|
||||
"KANTELE_G",
|
||||
"OCARINA_A",
|
||||
"OCARINA_B",
|
||||
"OCARINA_C",
|
||||
"OCARINA_D",
|
||||
"OCARINA_E",
|
||||
"OCARINA_F",
|
||||
"OCARINA_GSHARP",
|
||||
"OCARINA_A2",
|
||||
"RANDOM_SPELL",
|
||||
"DRAW_RANDOM",
|
||||
"DRAW_RANDOM_X3",
|
||||
"DRAW_3_RANDOM",
|
||||
"IF_PROJECTILE",
|
||||
"IF_HP",
|
||||
"IF_ENEMY",
|
||||
"IF_HALF",
|
||||
"IF_ELSE",
|
||||
"IF_END",
|
||||
"DUPLICATE",
|
||||
"SUMMON_PORTAL",
|
||||
"ALL_SPELLS",
|
||||
]
|
||||
|
||||
ALL_PERKS = [
|
||||
"CRITICAL_HIT",
|
||||
"BREATH_UNDERWATER",
|
||||
"EXTRA_MONEY",
|
||||
"EXTRA_MONEY_TRICK_KILL",
|
||||
"GOLD_IS_FOREVER",
|
||||
"TRICK_BLOOD_MONEY",
|
||||
"EXPLODING_GOLD",
|
||||
"HOVER_BOOST",
|
||||
"FASTER_LEVITATION",
|
||||
"MOVEMENT_FASTER",
|
||||
"LOW_GRAVITY",
|
||||
"HIGH_GRAVITY",
|
||||
"SPEED_DIVER",
|
||||
"STRONG_KICK",
|
||||
"TELEKINESIS",
|
||||
"REPELLING_CAPE",
|
||||
"EXPLODING_CORPSES",
|
||||
"SAVING_GRACE",
|
||||
"INVISIBILITY",
|
||||
"GLOBAL_GORE",
|
||||
"REMOVE_FOG_OF_WAR",
|
||||
"LEVITATION_TRAIL",
|
||||
"VAMPIRISM",
|
||||
"EXTRA_HP",
|
||||
"HEARTS_MORE_EXTRA_HP",
|
||||
"GLASS_CANNON",
|
||||
"LOW_HP_DAMAGE_BOOST",
|
||||
"RESPAWN",
|
||||
"WORM_ATTRACTOR",
|
||||
"WORM_DETRACTOR",
|
||||
"RADAR_ENEMY",
|
||||
"FOOD_CLOCK",
|
||||
"WAND_RADAR",
|
||||
"ITEM_RADAR",
|
||||
"MOON_RADAR",
|
||||
"PROTECTION_FIRE",
|
||||
"PROTECTION_RADIOACTIVITY",
|
||||
"PROTECTION_EXPLOSION",
|
||||
"PROTECTION_MELEE",
|
||||
"PROTECTION_ELECTRICITY",
|
||||
"TELEPORTITIS",
|
||||
"TELEPORTITIS_DODGE",
|
||||
"STAINLESS_ARMOUR",
|
||||
"EDIT_WANDS_EVERYWHERE",
|
||||
"NO_WAND_EDITING",
|
||||
"WAND_EXPERIMENTER",
|
||||
"ADVENTURER",
|
||||
"ABILITY_ACTIONS_MATERIALIZED",
|
||||
"PROJECTILE_HOMING",
|
||||
"PROJECTILE_HOMING_SHOOTER",
|
||||
"UNLIMITED_SPELLS",
|
||||
"FREEZE_FIELD",
|
||||
"FIRE_GAS",
|
||||
"DISSOLVE_POWDERS",
|
||||
"BLEED_SLIME",
|
||||
"BLEED_OIL",
|
||||
"BLEED_GAS",
|
||||
"SHIELD",
|
||||
"REVENGE_EXPLOSION",
|
||||
"REVENGE_TENTACLE",
|
||||
"REVENGE_RATS",
|
||||
"REVENGE_BULLET",
|
||||
"ATTACK_FOOT",
|
||||
"LEGGY_FEET",
|
||||
"PLAGUE_RATS",
|
||||
"VOMIT_RATS",
|
||||
"CORDYCEPS",
|
||||
"MOLD",
|
||||
"WORM_SMALLER_HOLES",
|
||||
"PROJECTILE_REPULSION",
|
||||
"RISKY_CRITICAL",
|
||||
"FUNGAL_DISEASE",
|
||||
"PROJECTILE_SLOW_FIELD",
|
||||
"PROJECTILE_REPULSION_SECTOR",
|
||||
"PROJECTILE_EATER_SECTOR",
|
||||
"ORBIT",
|
||||
"ANGRY_GHOST",
|
||||
"HUNGRY_GHOST",
|
||||
"DEATH_GHOST",
|
||||
"HOMUNCULUS",
|
||||
"ELECTRICITY",
|
||||
"ATTRACT_ITEMS",
|
||||
"EXTRA_KNOCKBACK",
|
||||
"LOWER_SPREAD",
|
||||
"LOW_RECOIL",
|
||||
"BOUNCE",
|
||||
"FAST_PROJECTILES",
|
||||
"ALWAYS_CAST",
|
||||
"EXTRA_MANA",
|
||||
"NO_MORE_SHUFFLE",
|
||||
"NO_MORE_KNOCKBACK",
|
||||
"DUPLICATE_PROJECTILE",
|
||||
"FASTER_WANDS",
|
||||
"EXTRA_SLOTS",
|
||||
"CONTACT_DAMAGE",
|
||||
"EXTRA_PERK",
|
||||
"PERKS_LOTTERY",
|
||||
"GAMBLE",
|
||||
"EXTRA_SHOP_ITEM",
|
||||
"GENOME_MORE_HATRED",
|
||||
"GENOME_MORE_LOVE",
|
||||
"PEACE_WITH_GODS",
|
||||
"MANA_FROM_KILLS",
|
||||
"ANGRY_LEVITATION",
|
||||
"LASER_AIM",
|
||||
"PERSONAL_LASER",
|
||||
"MEGA_BEAM_STONE",
|
||||
"IRON_STOMACH",
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1 @@
|
||||
# Create your views here.
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from '@hey-api/openapi-ts';
|
||||
|
||||
export default defineConfig({
|
||||
input: `http://localhost:7777/api/openapi.json`,
|
||||
output: 'src/api/',
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.utils import timezone
|
||||
from .models import (
|
||||
from opus_magnum.models import (
|
||||
SteamAPIKey,
|
||||
SteamCollection,
|
||||
SteamCollectionItem,
|
||||
@@ -81,6 +81,7 @@ class SteamCollectionAdmin(admin.ModelAdmin):
|
||||
"current_favorites",
|
||||
"last_fetched",
|
||||
"is_active",
|
||||
"accepting_submissions",
|
||||
]
|
||||
list_filter = ["is_active", "last_fetched", "created_at"]
|
||||
search_fields = ["title", "steam_id", "author_name", "description"]
|
||||
@@ -115,7 +116,7 @@ class SteamCollectionAdmin(admin.ModelAdmin):
|
||||
)
|
||||
},
|
||||
),
|
||||
("Status", {"fields": ("fetch_error",)}),
|
||||
("Status", {"fields": ("fetch_error", "accepting_submissions")}),
|
||||
)
|
||||
|
||||
|
||||
@@ -148,6 +149,7 @@ class SteamCollectionItemAdmin(admin.ModelAdmin):
|
||||
("Author Information", {"fields": ("author_name", "author_steam_id")}),
|
||||
("Metadata", {"fields": ("tags",)}),
|
||||
("Timestamps", {"fields": ("created_at", "updated_at")}),
|
||||
("Points factor", {"fields": ("points_factor", "points_value")}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,27 +1,40 @@
|
||||
from ninja import Router, File
|
||||
from ninja.files import UploadedFile
|
||||
from ninja.pagination import paginate
|
||||
from ninja.errors import HttpError
|
||||
from django.db import transaction
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
from django.shortcuts import get_object_or_404
|
||||
from typing import List
|
||||
|
||||
from opus_submitter.submissions.utils import verify_and_validate_ocr_date_for_submission
|
||||
from games.decorators import require_game_enabled
|
||||
from opus_magnum.utils import verify_and_validate_ocr_date_for_submission
|
||||
from ninja.decorators import decorate_view
|
||||
|
||||
from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem
|
||||
from .models import (
|
||||
Submission,
|
||||
PuzzleResponse,
|
||||
SubmissionFile,
|
||||
SteamCollectionItem,
|
||||
SteamCollection,
|
||||
)
|
||||
from .schemas import (
|
||||
SubmissionIn,
|
||||
SubmissionOut,
|
||||
PuzzleResponseOut,
|
||||
ValidationIn,
|
||||
SteamCollectionItemOut,
|
||||
SteamCollectionOut,
|
||||
)
|
||||
|
||||
router = Router()
|
||||
OPUS_MAGNUM_APP_ID = 558990
|
||||
|
||||
|
||||
@router.get("/puzzles", response=List[SteamCollectionItemOut])
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def list_puzzles(request):
|
||||
"""Get list of available puzzles"""
|
||||
return SteamCollectionItem.objects.select_related("collection").filter(
|
||||
@@ -29,7 +42,16 @@ def list_puzzles(request):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/collection", response=SteamCollectionOut)
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def get_collection(request):
|
||||
"""Get the active collection details"""
|
||||
collection = get_object_or_404(SteamCollection, is_active=True)
|
||||
return collection
|
||||
|
||||
|
||||
@router.get("/submissions", response=List[SubmissionOut])
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
@paginate
|
||||
def list_submissions(request):
|
||||
"""Get paginated list of submissions"""
|
||||
@@ -39,6 +61,7 @@ def list_submissions(request):
|
||||
|
||||
|
||||
@router.get("/submissions/{submission_id}", response=SubmissionOut)
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def get_submission(request, submission_id: str):
|
||||
"""Get detailed submission by ID"""
|
||||
return get_object_or_404(
|
||||
@@ -50,6 +73,7 @@ def get_submission(request, submission_id: str):
|
||||
|
||||
|
||||
@router.post("/submissions", response=SubmissionOut)
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def create_submission(
|
||||
request, data: SubmissionIn, files: List[UploadedFile] = File(...)
|
||||
):
|
||||
@@ -64,7 +88,15 @@ def create_submission(
|
||||
if len(files) < len(data.responses):
|
||||
return 400, {"detail": "Not enough files for all responses"}
|
||||
|
||||
print(data, files)
|
||||
# Check if collection is accepting submissions
|
||||
if data.responses:
|
||||
for puzzle in data.responses:
|
||||
if not get_object_or_404(
|
||||
SteamCollectionItem, id=puzzle.puzzle_id
|
||||
).collection.accepting_submissions:
|
||||
raise HttpError(
|
||||
403, "This tournament is no longer accepting submissions"
|
||||
)
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
@@ -112,12 +144,19 @@ def create_submission(
|
||||
ocr_confidence_cost=response_data.ocr_confidence_cost,
|
||||
ocr_confidence_cycles=response_data.ocr_confidence_cycles,
|
||||
ocr_confidence_area=response_data.ocr_confidence_area,
|
||||
**{
|
||||
# Put validated if not manual validation is needed
|
||||
"validated_cost": response_data.cost,
|
||||
"validated_cycles": response_data.cycles,
|
||||
"validated_area": response_data.area,
|
||||
}
|
||||
if not data.manual_validation_requested
|
||||
else {},
|
||||
)
|
||||
|
||||
# Process files for this response
|
||||
# For simplicity, we'll take one file per response
|
||||
# In a real implementation, you'd need better file-to-response mapping
|
||||
print("FI", file_index, files)
|
||||
if file_index < len(files):
|
||||
uploaded_file = files[file_index]
|
||||
|
||||
@@ -157,14 +196,17 @@ def create_submission(
|
||||
"responses__files", "responses__puzzle"
|
||||
).get(id=submission.id)
|
||||
|
||||
# Invalidate results cache on successful submission
|
||||
cache.delete("api:results:results")
|
||||
|
||||
return submission
|
||||
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return 500, {"detail": f"Error creating submission: {str(e)}"}
|
||||
|
||||
|
||||
@router.put("/responses/{response_id}/validate", response=PuzzleResponseOut)
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def validate_response(request, response_id: int, data: ValidationIn):
|
||||
"""Manually validate a puzzle response"""
|
||||
|
||||
@@ -193,11 +235,15 @@ def validate_response(request, response_id: int, data: ValidationIn):
|
||||
|
||||
response.save()
|
||||
|
||||
# Invalidate results cache when a response is validated
|
||||
cache.delete("api:results:results")
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.put("/responses/{response_id}/validate/auto", response=PuzzleResponseOut)
|
||||
def validate_response(request, response_id: int):
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def validate_auto(request, response_id: int):
|
||||
"""Try to auto validate a puzzle response"""
|
||||
|
||||
if not request.user.is_authenticated or not request.user.is_staff:
|
||||
@@ -212,6 +258,7 @@ def validate_response(request, response_id: int):
|
||||
|
||||
|
||||
@router.get("/responses/needs-validation", response=List[PuzzleResponseOut])
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def list_responses_needing_validation(request):
|
||||
"""Get all responses that need manual validation"""
|
||||
|
||||
@@ -227,6 +274,7 @@ def list_responses_needing_validation(request):
|
||||
|
||||
|
||||
@router.post("/submissions/{submission_id}/validate", response=SubmissionOut)
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def validate_submission(request, submission_id: str):
|
||||
"""Mark entire submission as validated"""
|
||||
|
||||
@@ -248,10 +296,14 @@ def validate_submission(request, submission_id: str):
|
||||
"responses__files", "responses__puzzle"
|
||||
).get(id=submission.id)
|
||||
|
||||
# Invalidate results cache when submission is validated
|
||||
cache.delete("api:results:results")
|
||||
|
||||
return submission
|
||||
|
||||
|
||||
@router.delete("/submissions/{submission_id}")
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def delete_submission(request, submission_id: str):
|
||||
"""Delete a submission (admin only)"""
|
||||
|
||||
@@ -260,10 +312,15 @@ def delete_submission(request, submission_id: str):
|
||||
|
||||
submission = get_object_or_404(Submission, id=submission_id)
|
||||
submission.delete()
|
||||
|
||||
# Invalidate results cache when submission is deleted
|
||||
cache.delete("api:results:results")
|
||||
|
||||
return {"detail": "Submission deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def get_stats(request):
|
||||
"""Get submission statistics"""
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class OpusMagnumConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "opus_magnum"
|
||||
+4
-4
@@ -3,8 +3,8 @@ Django management command to fetch Steam Workshop collections
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from submissions.utils import create_or_update_collection
|
||||
from submissions.models import SteamAPIKey, SteamCollection
|
||||
from opus_magnum.utils import create_or_update_collection
|
||||
from opus_magnum.models import SteamAPIKey, SteamCollection
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -27,14 +27,14 @@ class Command(BaseCommand):
|
||||
api_key = SteamAPIKey.objects.filter(is_active=True).first()
|
||||
|
||||
if not api_key:
|
||||
self.stderr.write(f"No API key defined! Aborting...")
|
||||
self.stderr.write("No API key defined! Aborting...")
|
||||
return
|
||||
|
||||
self.stdout.write(f"Using api key: {api_key}")
|
||||
|
||||
try:
|
||||
# Check if collection already exists
|
||||
from submissions.utils import SteamCollectionFetcher
|
||||
from opus_magnum.utils import SteamCollectionFetcher
|
||||
|
||||
fetcher = SteamCollectionFetcher(api_key.api_key)
|
||||
collection_id = fetcher.extract_collection_id(url)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user