user info + max size

This commit is contained in:
Loïc Gremaud 2025-10-29 03:31:32 +01:00
parent 52723b200a
commit 961e14bd43
10 changed files with 210 additions and 53 deletions

View File

@ -1,6 +1,7 @@
from ninja import NinjaAPI from ninja import NinjaAPI
from ninja.security import django_auth from ninja.security import django_auth
from submissions.api import router as submissions_router from submissions.api import router as submissions_router
from submissions.schemas import UserInfoOut
# Create the main API instance # Create the main API instance
api = NinjaAPI( api = NinjaAPI(
@ -39,3 +40,29 @@ def api_info(request):
"Admin validation tools", "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,
}

View File

@ -133,24 +133,24 @@ AUTH_USER_MODEL = "accounts.CustomUser"
CAS_SERVER_URL = "https://polylan.ch/cas/" CAS_SERVER_URL = "https://polylan.ch/cas/"
# Steam API Configuration # Steam API Configuration
STEAM_API_KEY = os.environ.get('STEAM_API_KEY', None) # Set via environment variable STEAM_API_KEY = os.environ.get("STEAM_API_KEY", None) # Set via environment variable
# File Upload Settings # File Upload Settings
MEDIA_URL = '/media/' MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / 'media' MEDIA_ROOT = BASE_DIR / "media"
# File Upload Limits # File Upload Limits
FILE_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 # 10MB FILE_UPLOAD_MAX_MEMORY_SIZE = 256 * 1024 * 1024 # 256MB
DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 # 10MB DATA_UPLOAD_MAX_MEMORY_SIZE = 256 * 1024 * 1024 # 256MB
# Allowed file types for submissions # Allowed file types for submissions
ALLOWED_SUBMISSION_TYPES = [ ALLOWED_SUBMISSION_TYPES = [
'image/jpeg', "image/jpeg",
'image/jpg', "image/jpg",
'image/png', "image/png",
'image/gif', "image/gif",
'video/mp4', "video/mp4",
'video/webm' "video/webm",
] ]
# Authentication backends # Authentication backends

View File

@ -2,13 +2,15 @@
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import PuzzleCard from './components/PuzzleCard.vue' import PuzzleCard from './components/PuzzleCard.vue'
import SubmissionForm from './components/SubmissionForm.vue' import SubmissionForm from './components/SubmissionForm.vue'
import { puzzleHelpers, submissionHelpers, errorHelpers } from './services/apiService' import AdminPanel from './components/AdminPanel.vue'
import type { SteamCollection, SteamCollectionItem, Submission, PuzzleResponse } from './types' import { puzzleHelpers, submissionHelpers, errorHelpers, apiService } from './services/apiService'
import type { SteamCollection, SteamCollectionItem, Submission, PuzzleResponse, UserInfo } from './types'
// API data // API data
const collections = ref<SteamCollection[]>([]) const collections = ref<SteamCollection[]>([])
const puzzles = ref<SteamCollectionItem[]>([]) const puzzles = ref<SteamCollectionItem[]>([])
const submissions = ref<Submission[]>([]) const submissions = ref<Submission[]>([])
const userInfo = ref<UserInfo | null>(null)
const isLoading = ref(true) const isLoading = ref(true)
const showSubmissionModal = ref(false) const showSubmissionModal = ref(false)
const error = ref<string>('') const error = ref<string>('')
@ -92,15 +94,22 @@ const mockPuzzles: SteamCollectionItem[] = [
} }
] ]
// Computed properties
const isSuperuser = computed(() => {
return userInfo.value?.is_superuser || false
})
// Computed property to get responses grouped by puzzle // Computed property to get responses grouped by puzzle
const responsesByPuzzle = computed(() => { const responsesByPuzzle = computed(() => {
const grouped: Record<number, PuzzleResponse[]> = {} const grouped: Record<number, PuzzleResponse[]> = {}
submissions.value.forEach(submission => { submissions.value.forEach(submission => {
submission.responses.forEach(response => { submission.responses.forEach(response => {
if (!grouped[response.puzzle_id]) { // Handle both number and object types for puzzle field
grouped[response.puzzle_id] = [] const puzzleId = typeof response.puzzle === 'number' ? response.puzzle : response.puzzle.id
if (!grouped[puzzleId]) {
grouped[puzzleId] = []
} }
grouped[response.puzzle_id].push(response) grouped[puzzleId].push(response)
}) })
}) })
return grouped return grouped
@ -111,9 +120,23 @@ onMounted(async () => {
isLoading.value = true isLoading.value = true
error.value = '' error.value = ''
console.log('Starting data load...')
// Load user info
console.log('Loading user info...')
const userResponse = await apiService.getUserInfo()
if (userResponse.data) {
userInfo.value = userResponse.data
console.log('User info loaded:', userResponse.data)
} else if (userResponse.error) {
console.warn('User info error:', userResponse.error)
}
// Load puzzles from API // Load puzzles from API
console.log('Loading puzzles...')
const loadedPuzzles = await puzzleHelpers.loadPuzzles() const loadedPuzzles = await puzzleHelpers.loadPuzzles()
puzzles.value = loadedPuzzles puzzles.value = loadedPuzzles
console.log('Puzzles loaded:', loadedPuzzles.length)
// Create mock collection from loaded puzzles for display // Create mock collection from loaded puzzles for display
if (loadedPuzzles.length > 0) { if (loadedPuzzles.length > 0) {
@ -129,17 +152,23 @@ onMounted(async () => {
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
updated_at: new Date().toISOString() updated_at: new Date().toISOString()
}] }]
console.log('Collection created')
} }
// Load existing submissions // Load existing submissions
console.log('Loading submissions...')
const loadedSubmissions = await submissionHelpers.loadSubmissions() const loadedSubmissions = await submissionHelpers.loadSubmissions()
submissions.value = loadedSubmissions submissions.value = loadedSubmissions
console.log('Submissions loaded:', loadedSubmissions.length)
console.log('Data load complete!')
} catch (err) { } catch (err) {
error.value = errorHelpers.getErrorMessage(err) error.value = errorHelpers.getErrorMessage(err)
console.error('Failed to load data:', err) console.error('Failed to load data:', err)
} finally { } finally {
isLoading.value = false isLoading.value = false
console.log('Loading state set to false')
} }
}) })
@ -208,6 +237,17 @@ const findPuzzleByName = (ocrPuzzleName: string): SteamCollectionItem | null =>
<div class="flex-1"> <div class="flex-1">
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1> <h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
</div> </div>
<div class="flex-none">
<div v-if="userInfo?.is_authenticated" class="flex items-center gap-2">
<div class="text-sm">
<span class="font-medium">{{ userInfo.username }}</span>
<span v-if="userInfo.is_superuser" class="badge badge-warning badge-xs ml-1">Admin</span>
</div>
</div>
<div v-else class="text-sm text-base-content/70">
Not logged in
</div>
</div>
</div> </div>
</div> </div>
@ -255,6 +295,11 @@ const findPuzzleByName = (ocrPuzzleName: string): SteamCollectionItem | null =>
</div> </div>
</div> </div>
<!-- Admin Panel (only for superusers) -->
<div v-if="isSuperuser">
<AdminPanel />
</div>
<!-- Puzzles Grid --> <!-- Puzzles Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<PuzzleCard <PuzzleCard

View File

@ -183,10 +183,22 @@ const loadData = async () => {
try { try {
isLoading.value = true isLoading.value = true
// Load stats // Load stats (skip if endpoint doesn't exist)
const statsResponse = await apiService.getStats() try {
if (statsResponse.data) { const statsResponse = await apiService.getStats()
stats.value = statsResponse.data if (statsResponse.data) {
stats.value = statsResponse.data
}
} catch (error) {
console.warn('Stats endpoint not available:', error)
// Set default stats
stats.value = {
total_submissions: 0,
total_responses: 0,
needs_validation: 0,
validated_submissions: 0,
validation_rate: 0
}
} }
// Load responses needing validation // Load responses needing validation

View File

@ -37,7 +37,7 @@
</button> </button>
</div> </div>
<p class="text-xs text-base-content/50"> <p class="text-xs text-base-content/50">
Supported formats: JPG, PNG, GIF (max 10MB each) Supported formats: JPG, PNG, GIF (max 256MB each)
</p> </p>
</div> </div>
@ -238,9 +238,9 @@ const isValidFile = (file: File): boolean => {
return false return false
} }
// Check file size (10MB limit) // Check file size (256MB limit)
if (file.size > 10 * 1024 * 1024) { if (file.size > 256 * 1024 * 1024) {
error.value = `${file.name} is too large (max 10MB)` error.value = `${file.name} is too large (max 256MB)`
return false return false
} }

View File

@ -48,7 +48,7 @@
<span class="text-sm font-medium">Solutions ({{ responses.length }})</span> <span class="text-sm font-medium">Solutions ({{ responses.length }})</span>
</div> </div>
<div class="overflow-x-auto"> <div>
<table class="table table-xs"> <table class="table table-xs">
<thead> <thead>
<tr> <tr>

View File

@ -2,7 +2,8 @@ import type {
SteamCollectionItem, SteamCollectionItem,
Submission, Submission,
PuzzleResponse, PuzzleResponse,
SubmissionFile SubmissionFile,
UserInfo
} from '../types' } from '../types'
// API Configuration // API Configuration
@ -179,6 +180,11 @@ export class ApiService {
async healthCheck(): Promise<ApiResponse<{ status: string; service: string }>> { async healthCheck(): Promise<ApiResponse<{ status: string; service: string }>> {
return this.request<{ status: string; service: string }>('/health') return this.request<{ status: string; service: string }>('/health')
} }
// User info
async getUserInfo(): Promise<ApiResponse<UserInfo>> {
return this.request<UserInfo>('/user')
}
} }
// Singleton instance // Singleton instance

View File

@ -73,3 +73,15 @@ export interface Submission {
created_at?: string created_at?: string
updated_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[]
}

View File

@ -5,13 +5,13 @@ from django.db import transaction
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.http import Http404 from django.http import Http404
from django.utils import timezone from django.utils import timezone
from django.shortcuts import get_object_or_404
from typing import List from typing import List
from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem
from .schemas import ( from .schemas import (
SubmissionIn, SubmissionIn,
SubmissionOut, SubmissionOut,
SubmissionListOut,
PuzzleResponseOut, PuzzleResponseOut,
ValidationIn, ValidationIn,
SteamCollectionItemOut, SteamCollectionItemOut,
@ -26,23 +26,24 @@ def list_puzzles(request):
return SteamCollectionItem.objects.select_related("collection").all() return SteamCollectionItem.objects.select_related("collection").all()
@router.get("/submissions", response=List[SubmissionListOut]) @router.get("/submissions", response=List[SubmissionOut])
@paginate @paginate
def list_submissions(request): def list_submissions(request):
"""Get paginated list of submissions""" """Get paginated list of submissions"""
return Submission.objects.prefetch_related("responses").all() return Submission.objects.prefetch_related(
"responses__files", "responses__puzzle"
).filter(user=request.user)
@router.get("/submissions/{submission_id}", response=SubmissionOut) @router.get("/submissions/{submission_id}", response=SubmissionOut)
def get_submission(request, submission_id: str): def get_submission(request, submission_id: str):
"""Get detailed submission by ID""" """Get detailed submission by ID"""
try: return get_object_or_404(
submission = Submission.objects.prefetch_related( Submission.objects.prefetch_related(
"responses__files", "responses__puzzle" "responses__files", "responses__puzzle"
).get(id=submission_id) ).filter(user=request.user),
return submission id=submission_id,
except Submission.DoesNotExist: )
raise Http404("Submission not found")
@router.post("/submissions", response=SubmissionOut) @router.post("/submissions", response=SubmissionOut)
@ -104,9 +105,9 @@ def create_submission(
"detail": f"Invalid file type: {uploaded_file.content_type}" "detail": f"Invalid file type: {uploaded_file.content_type}"
} }
# Validate file size (10MB limit) # Validate file size (256MB limit)
if uploaded_file.size > 10 * 1024 * 1024: if uploaded_file.size > 256 * 1024 * 1024:
return 400, {"detail": "File too large (max 10MB)"} return 400, {"detail": "File too large (max 256MB)"}
# Create submission file # Create submission file
submission_file = SubmissionFile.objects.create( submission_file = SubmissionFile.objects.create(

View File

@ -10,6 +10,7 @@ from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionI
# Input Schemas # Input Schemas
class SubmissionFileIn(Schema): class SubmissionFileIn(Schema):
"""Schema for file upload data""" """Schema for file upload data"""
original_filename: str original_filename: str
content_type: str content_type: str
ocr_data: Optional[dict] = None ocr_data: Optional[dict] = None
@ -17,6 +18,7 @@ class SubmissionFileIn(Schema):
class PuzzleResponseIn(Schema): class PuzzleResponseIn(Schema):
"""Schema for creating a puzzle response""" """Schema for creating a puzzle response"""
puzzle_id: int puzzle_id: int
puzzle_name: str puzzle_name: str
cost: Optional[str] = None cost: Optional[str] = None
@ -28,6 +30,7 @@ class PuzzleResponseIn(Schema):
class SubmissionIn(Schema): class SubmissionIn(Schema):
"""Schema for creating a submission""" """Schema for creating a submission"""
notes: Optional[str] = None notes: Optional[str] = None
responses: List[PuzzleResponseIn] responses: List[PuzzleResponseIn]
@ -35,51 +38,76 @@ class SubmissionIn(Schema):
# Output Schemas # Output Schemas
class SubmissionFileOut(ModelSchema): class SubmissionFileOut(ModelSchema):
"""Schema for submission file output""" """Schema for submission file output"""
file_url: Optional[str] file_url: Optional[str]
class Meta: class Meta:
model = SubmissionFile model = SubmissionFile
fields = [ fields = [
'id', 'original_filename', 'file_size', 'content_type', "id",
'ocr_processed', 'ocr_raw_data', 'ocr_error', 'created_at' "original_filename",
"file_size",
"content_type",
"ocr_processed",
"ocr_raw_data",
"ocr_error",
"created_at",
] ]
class PuzzleResponseOut(ModelSchema): class PuzzleResponseOut(ModelSchema):
"""Schema for puzzle response output""" """Schema for puzzle response output"""
files: List[SubmissionFileOut] files: List[SubmissionFileOut]
final_cost: Optional[str] final_cost: Optional[str]
final_cycles: Optional[str] final_cycles: Optional[str]
final_area: Optional[str] final_area: Optional[str]
class Meta: class Meta:
model = PuzzleResponse model = PuzzleResponse
fields = [ fields = [
'id', 'puzzle', 'puzzle_name', 'cost', 'cycles', 'area', "id",
'needs_manual_validation', 'ocr_confidence_score', "puzzle",
'validated_cost', 'validated_cycles', 'validated_area', "puzzle_name",
'created_at', 'updated_at' "cost",
"cycles",
"area",
"needs_manual_validation",
"ocr_confidence_score",
"validated_cost",
"validated_cycles",
"validated_area",
"created_at",
"updated_at",
] ]
class SubmissionOut(ModelSchema): class SubmissionOut(ModelSchema):
"""Schema for submission output""" """Schema for submission output"""
responses: List[PuzzleResponseOut] responses: List[PuzzleResponseOut]
total_responses: int total_responses: int
needs_validation: bool needs_validation: bool
class Meta: class Meta:
model = Submission model = Submission
fields = [ fields = [
'id', 'user', 'notes', 'is_validated', 'validated_by', "id",
'validated_at', 'created_at', 'updated_at' "user",
"notes",
"is_validated",
"validated_by",
"validated_at",
"created_at",
"updated_at",
] ]
class SubmissionListOut(Schema): class SubmissionListOut(Schema):
"""Schema for submission list output""" """Schema for submission list output"""
id: UUID id: UUID
user: Optional[int] # user: int
notes: Optional[str] notes: Optional[str]
total_responses: int total_responses: int
needs_validation: bool needs_validation: bool
@ -91,6 +119,7 @@ class SubmissionListOut(Schema):
# Validation Schemas # Validation Schemas
class ValidationIn(Schema): class ValidationIn(Schema):
"""Schema for manual validation input""" """Schema for manual validation input"""
validated_cost: Optional[str] = None validated_cost: Optional[str] = None
validated_cycles: Optional[str] = None validated_cycles: Optional[str] = None
validated_area: Optional[str] = None validated_area: Optional[str] = None
@ -99,24 +128,49 @@ class ValidationIn(Schema):
# Collection Schemas # Collection Schemas
class SteamCollectionItemOut(ModelSchema): class SteamCollectionItemOut(ModelSchema):
"""Schema for Steam collection item output""" """Schema for Steam collection item output"""
steam_url: str steam_url: str
class Meta: class Meta:
model = SteamCollectionItem model = SteamCollectionItem
fields = [ fields = [
'id', 'steam_item_id', 'title', 'author_name', 'description', "id",
'tags', 'order_index', 'created_at', 'updated_at' "steam_item_id",
"title",
"author_name",
"description",
"tags",
"order_index",
"created_at",
"updated_at",
] ]
# Error Schemas # Error Schemas
class ErrorOut(Schema): class ErrorOut(Schema):
"""Schema for error responses""" """Schema for error responses"""
detail: str detail: str
code: Optional[str] = None code: Optional[str] = None
class ValidationErrorOut(Schema): class ValidationErrorOut(Schema):
"""Schema for validation error responses""" """Schema for validation error responses"""
detail: str detail: str
errors: dict errors: dict
# User Schemas
class UserInfoOut(Schema):
"""Schema for user information output"""
id: Optional[int] = None
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
email: Optional[str] = None
is_authenticated: bool
is_staff: bool
is_superuser: bool
cas_groups: Optional[List[str]] = None