basic noita submissions
This commit is contained in:
parent
01b0dbd1d9
commit
119fdc2a51
0
polylan_submitter/noita/__init__.py
Normal file
0
polylan_submitter/noita/__init__.py
Normal file
1
polylan_submitter/noita/admin.py
Normal file
1
polylan_submitter/noita/admin.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Register your models here.
|
||||||
67
polylan_submitter/noita/api.py
Normal file
67
polylan_submitter/noita/api.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
from django.http import HttpRequest
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from ninja import Router, File
|
||||||
|
from ninja.files import UploadedFile
|
||||||
|
|
||||||
|
from .models import LogfileSubmission
|
||||||
|
from .schemas import NoitaSubmissionOut
|
||||||
|
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("submit", response=NoitaSubmissionOut)
|
||||||
|
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",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
"video/mp4",
|
||||||
|
"video/webm",
|
||||||
|
]
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)}"}
|
||||||
6
polylan_submitter/noita/apps.py
Normal file
6
polylan_submitter/noita/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class NoitaConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "noita"
|
||||||
61
polylan_submitter/noita/migrations/0001_initial.py
Normal file
61
polylan_submitter/noita/migrations/0001_initial.py
Normal file
@ -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
polylan_submitter/noita/migrations/__init__.py
Normal file
0
polylan_submitter/noita/migrations/__init__.py
Normal file
44
polylan_submitter/noita/models.py
Normal file
44
polylan_submitter/noita/models.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
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)
|
||||||
16
polylan_submitter/noita/schemas.py
Normal file
16
polylan_submitter/noita/schemas.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class NoitaSubmissionOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: Optional[int]
|
||||||
|
username: Optional[str]
|
||||||
|
file_size: int
|
||||||
|
content_type: str
|
||||||
|
created_at: datetime
|
||||||
|
processed: bool
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
1
polylan_submitter/noita/tests.py
Normal file
1
polylan_submitter/noita/tests.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Create your tests here.
|
||||||
1
polylan_submitter/noita/views.py
Normal file
1
polylan_submitter/noita/views.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Create your views here.
|
||||||
@ -2,6 +2,7 @@ from ninja import NinjaAPI
|
|||||||
from submissions.api import router as submissions_router
|
from submissions.api import router as submissions_router
|
||||||
from submissions.schemas import UserInfoOut
|
from submissions.schemas import UserInfoOut
|
||||||
from animations.api import router as results_router
|
from animations.api import router as results_router
|
||||||
|
from noita.api import router as noita_router
|
||||||
|
|
||||||
# Create the main API instance
|
# Create the main API instance
|
||||||
api = NinjaAPI(
|
api = NinjaAPI(
|
||||||
@ -28,6 +29,7 @@ It provides features for user authentication, puzzle listing, submission uploads
|
|||||||
# Include the submissions router
|
# Include the submissions router
|
||||||
api.add_router("/submissions/", submissions_router, tags=["submissions"])
|
api.add_router("/submissions/", submissions_router, tags=["submissions"])
|
||||||
api.add_router("/results/", results_router, tags=["results"])
|
api.add_router("/results/", results_router, tags=["results"])
|
||||||
|
api.add_router("/noita/", noita_router, tags=["noita"])
|
||||||
|
|
||||||
|
|
||||||
# Health check endpoint
|
# Health check endpoint
|
||||||
|
|||||||
@ -42,6 +42,7 @@ INSTALLED_APPS = [
|
|||||||
"accounts",
|
"accounts",
|
||||||
"animations",
|
"animations",
|
||||||
"submissions",
|
"submissions",
|
||||||
|
"noita",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
@ -1,23 +1,220 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
|
||||||
|
const userInfo = ref({
|
||||||
|
username: "Player",
|
||||||
|
rank: 42,
|
||||||
|
score: 15420,
|
||||||
|
runsSubmitted: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadedFiles = ref<File[]>([]);
|
||||||
|
const isUploading = ref(false);
|
||||||
|
const isDragover = ref(false);
|
||||||
|
|
||||||
|
const handleFileUpload = (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (input.files) {
|
||||||
|
uploadedFiles.value = Array.from(input.files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragover = (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
isDragover.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragleave = (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
isDragover.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
isDragover.value = false;
|
||||||
|
|
||||||
|
if (event.dataTransfer?.files) {
|
||||||
|
uploadedFiles.value = Array.from(event.dataTransfer.files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitRun = async () => {
|
||||||
|
if (uploadedFiles.value.length === 0) return;
|
||||||
|
|
||||||
|
isUploading.value = true;
|
||||||
|
try {
|
||||||
|
for (const file of uploadedFiles.value) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
const response = await fetch("/api/noita/submit", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(`Error submitting ${file.name}: ${error.detail || "Unknown error"}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log("Submission successful:", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadedFiles.value = [];
|
||||||
|
alert("Run submitted successfully!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error submitting run:", error);
|
||||||
|
alert("Error submitting run. Please try again.");
|
||||||
|
} finally {
|
||||||
|
isUploading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goHome = () => {
|
||||||
|
window.location.href = "/";
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-base-200 flex items-center justify-center px-4">
|
<div class="min-h-screen bg-base-200">
|
||||||
<div class="w-full max-w-2xl">
|
<!-- Header -->
|
||||||
<div class="card bg-base-100 shadow-xl">
|
<div class="navbar bg-base-100 shadow-lg">
|
||||||
<div class="bg-gradient-to-br from-purple-600 to-purple-400 p-8 text-white">
|
<div class="container mx-auto w-full flex items-center gap-4">
|
||||||
<i class="mdi mdi-wand-plus text-6xl"></i>
|
<button @click="goHome" class="btn btn-primary btn-sm">
|
||||||
|
<i class="mdi mdi-arrow-left"></i>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<h1 class="text-xl font-bold">Noita Submitter</h1>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<a href="/api/docs" class="btn btn-xs">API docs</a>
|
||||||
|
<a href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
<!-- Left Column: User Ranking -->
|
||||||
|
<div class="lg:col-span-1">
|
||||||
|
<div class="card bg-base-100 shadow-lg sticky top-8">
|
||||||
|
<div class="bg-gradient-to-br from-purple-600 to-purple-400 p-6 text-white rounded-t-2xl">
|
||||||
|
<i class="mdi mdi-trophy text-4xl"></i>
|
||||||
|
<h2 class="text-2xl font-bold mt-2">Your Ranking</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<p class="text-sm text-base-content/70">Player</p>
|
||||||
|
<p class="text-3xl font-bold">{{ userInfo.username }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-base-content/70 mb-1">Current Rank</p>
|
||||||
|
<p class="text-4xl font-bold text-primary">#{{ userInfo.rank }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-base-content/70 mb-1">Total Score</p>
|
||||||
|
<p class="text-2xl font-bold">{{ userInfo.score.toLocaleString() }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-base-content/70 mb-1">Runs Submitted</p>
|
||||||
|
<p class="text-2xl font-bold">{{ userInfo.runsSubmitted }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-outline btn-sm w-full mt-6">
|
||||||
|
<i class="mdi mdi-refresh mr-1"></i>
|
||||||
|
View Full Leaderboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body text-center">
|
|
||||||
<h1 class="card-title text-4xl justify-center">Noita Submitter</h1>
|
<!-- Right Column: Upload -->
|
||||||
<p class="text-lg text-base-content/70 mt-4">
|
<div class="lg:col-span-2">
|
||||||
Coming soon! Noita submission system is under development.
|
<div class="card bg-base-100 shadow-lg">
|
||||||
</p>
|
<div class="card-body">
|
||||||
<div class="card-actions justify-center mt-8">
|
<h2 class="card-title text-2xl mb-6">
|
||||||
<a href="/" class="btn btn-primary">
|
<i class="mdi mdi-cloud-upload text-purple-500 mr-2"></i>
|
||||||
<i class="mdi mdi-arrow-left mr-2"></i>
|
Submit Your Run
|
||||||
Back to Home
|
</h2>
|
||||||
</a>
|
|
||||||
|
<!-- Upload Area -->
|
||||||
|
<div
|
||||||
|
@dragover="handleDragover"
|
||||||
|
@dragleave="handleDragleave"
|
||||||
|
@drop="handleDrop"
|
||||||
|
:class="[
|
||||||
|
'border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer bg-base-200/50 mb-6',
|
||||||
|
isDragover ? 'border-primary bg-primary/10' : 'border-base-300 hover:border-primary'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
@change="handleFileUpload"
|
||||||
|
class="hidden"
|
||||||
|
id="file-upload"
|
||||||
|
accept="video/*,image/*"
|
||||||
|
/>
|
||||||
|
<label for="file-upload" class="cursor-pointer flex flex-col items-center gap-3">
|
||||||
|
<i :class="['mdi text-4xl', isDragover ? 'mdi-cloud-check text-primary' : 'mdi-file-upload text-base-content/50']"></i>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold">Click to upload or drag and drop</p>
|
||||||
|
<p class="text-sm text-base-content/70">Video or image files (MP4, PNG, etc.)</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Uploaded Files List -->
|
||||||
|
<div v-if="uploadedFiles.length > 0" class="mb-6">
|
||||||
|
<p class="font-semibold mb-3">Selected Files:</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div v-for="(file, index) in uploadedFiles" :key="index" class="flex items-center gap-3 bg-base-200 p-3 rounded-lg">
|
||||||
|
<i class="mdi mdi-file text-primary"></i>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium truncate">{{ file.name }}</p>
|
||||||
|
<p class="text-xs text-base-content/70">{{ (file.size / 1024 / 1024).toFixed(2) }} MB</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="uploadedFiles.splice(index, 1)"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
>
|
||||||
|
<i class="mdi mdi-close"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<label for="file-upload" class="btn btn-outline flex-1">
|
||||||
|
<i class="mdi mdi-folder-open mr-2"></i>
|
||||||
|
Choose Files
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
@click="submitRun"
|
||||||
|
:disabled="uploadedFiles.length === 0 || isUploading"
|
||||||
|
:class="['btn btn-primary flex-1', { 'loading': isUploading }]"
|
||||||
|
>
|
||||||
|
<i v-if="!isUploading" class="mdi mdi-send mr-2"></i>
|
||||||
|
{{ isUploading ? 'Submitting...' : 'Submit Run' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-base-content/70 text-center mt-4">
|
||||||
|
Maximum file size: 256 MB per file
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user