ruff + submissions validation
This commit is contained in:
@@ -9,24 +9,28 @@ class CustomUserAdmin(UserAdmin):
|
||||
|
||||
# Add custom fields to the user admin
|
||||
fieldsets = UserAdmin.fieldsets + (
|
||||
('CAS Information', {
|
||||
'fields': ('cas_user_id', 'cas_groups', 'cas_attributes'),
|
||||
}),
|
||||
(
|
||||
"CAS Information",
|
||||
{
|
||||
"fields": ("cas_user_id", "cas_groups", "cas_attributes"),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
# Add custom fields to the list display
|
||||
list_display = UserAdmin.list_display + ('cas_user_id', 'get_cas_groups_display')
|
||||
list_display = UserAdmin.list_display + ("cas_user_id", "get_cas_groups_display")
|
||||
|
||||
# Add search fields
|
||||
search_fields = UserAdmin.search_fields + ('cas_user_id',)
|
||||
search_fields = UserAdmin.search_fields + ("cas_user_id",)
|
||||
|
||||
# Add filters
|
||||
list_filter = UserAdmin.list_filter + ('cas_groups',)
|
||||
list_filter = UserAdmin.list_filter + ("cas_groups",)
|
||||
|
||||
# Make CAS fields readonly in admin
|
||||
readonly_fields = ('cas_user_id', 'cas_groups', 'cas_attributes')
|
||||
readonly_fields = ("cas_user_id", "cas_groups", "cas_attributes")
|
||||
|
||||
def get_cas_groups_display(self, obj):
|
||||
"""Display CAS groups in admin list."""
|
||||
return obj.get_cas_groups_display()
|
||||
get_cas_groups_display.short_description = 'CAS Groups'
|
||||
|
||||
get_cas_groups_display.short_description = "CAS Groups"
|
||||
|
||||
@@ -2,5 +2,5 @@ from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'accounts'
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "accounts"
|
||||
|
||||
@@ -7,41 +7,131 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CustomUser',
|
||||
name="CustomUser",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('cas_user_id', models.CharField(blank=True, max_length=50, null=True, unique=True)),
|
||||
('cas_groups', models.JSONField(blank=True, default=list)),
|
||||
('cas_attributes', models.JSONField(blank=True, default=dict)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||
(
|
||||
"last_login",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="last login"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_superuser",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||
verbose_name="superuser status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"username",
|
||||
models.CharField(
|
||||
error_messages={
|
||||
"unique": "A user with that username already exists."
|
||||
},
|
||||
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||
max_length=150,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.contrib.auth.validators.UnicodeUsernameValidator()
|
||||
],
|
||||
verbose_name="username",
|
||||
),
|
||||
),
|
||||
(
|
||||
"first_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="first name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"last_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="last name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
blank=True, max_length=254, verbose_name="email address"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_staff",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates whether the user can log into this admin site.",
|
||||
verbose_name="staff status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||
verbose_name="active",
|
||||
),
|
||||
),
|
||||
(
|
||||
"date_joined",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now, verbose_name="date joined"
|
||||
),
|
||||
),
|
||||
(
|
||||
"cas_user_id",
|
||||
models.CharField(blank=True, max_length=50, null=True, unique=True),
|
||||
),
|
||||
("cas_groups", models.JSONField(blank=True, default=list)),
|
||||
("cas_attributes", models.JSONField(blank=True, default=dict)),
|
||||
(
|
||||
"groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.group",
|
||||
verbose_name="groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_permissions",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Specific permissions for this user.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.permission",
|
||||
verbose_name="user permissions",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'abstract': False,
|
||||
"verbose_name": "user",
|
||||
"verbose_name_plural": "users",
|
||||
"abstract": False,
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
("objects", django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
import json
|
||||
|
||||
|
||||
class CustomUser(AbstractUser):
|
||||
@@ -57,4 +56,3 @@ class CustomUser(AbstractUser):
|
||||
self.cas_attributes = attributes
|
||||
|
||||
self.save()
|
||||
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'opus_submitter.settings')
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "opus_submitter.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
@@ -18,5 +19,5 @@ def main():
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from ninja import NinjaAPI
|
||||
from ninja.security import django_auth
|
||||
from submissions.api import router as submissions_router
|
||||
from submissions.schemas import UserInfoOut
|
||||
|
||||
@@ -58,7 +57,7 @@ def get_user_info(request):
|
||||
"is_authenticated": True,
|
||||
"is_staff": user.is_staff,
|
||||
"is_superuser": user.is_superuser,
|
||||
"cas_groups": getattr(user, 'cas_groups', [])
|
||||
"cas_groups": getattr(user, "cas_groups", []),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
|
||||
@@ -11,6 +11,6 @@ import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'opus_submitter.settings')
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "opus_submitter.settings")
|
||||
|
||||
application = get_asgi_application()
|
||||
|
||||
@@ -176,6 +176,4 @@ STATICFILES_DIRS = [
|
||||
os.path.join(BASE_DIR, "static_source/vite"),
|
||||
]
|
||||
|
||||
JWT_SECRET_KEY = "rooCaimosaicae3Oos2quezieb9rohsem1eufieJoo"
|
||||
|
||||
from opus_submitter.settingsLocal import *
|
||||
from opus_submitter.settingsLocal import * # noqa
|
||||
|
||||
@@ -11,6 +11,6 @@ import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'opus_submitter.settings')
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "opus_submitter.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
|
||||
@@ -14,11 +14,13 @@ class SimpleCASLoginView(View):
|
||||
"""Simple CAS login view."""
|
||||
|
||||
def get(self, request):
|
||||
ticket = request.GET.get('ticket')
|
||||
ticket = request.GET.get("ticket")
|
||||
|
||||
if ticket:
|
||||
# Coming back from CAS with ticket - validate it
|
||||
service_url = request.build_absolute_uri().split('?')[0] # Remove query params
|
||||
service_url = request.build_absolute_uri().split("?")[
|
||||
0
|
||||
] # Remove query params
|
||||
|
||||
user = authenticate(request=request, ticket=ticket, service=service_url)
|
||||
|
||||
@@ -30,7 +32,9 @@ class SimpleCASLoginView(View):
|
||||
|
||||
else:
|
||||
# No ticket - redirect to CAS
|
||||
service_url = request.build_absolute_uri().split('?')[0] # Remove query params
|
||||
service_url = request.build_absolute_uri().split("?")[
|
||||
0
|
||||
] # Remove query params
|
||||
cas_login_url = f"{settings.CAS_SERVER_URL.rstrip('/')}/login?service={urllib.parse.quote(service_url)}"
|
||||
return redirect(cas_login_url)
|
||||
|
||||
|
||||
@@ -146,6 +146,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual Puzzle Selection (when OCR confidence is low) -->
|
||||
<div v-if="file.needsManualPuzzleSelection" class="mt-2">
|
||||
<div class="alert alert-warning alert-sm">
|
||||
<i class="mdi mdi-alert-circle text-lg"></i>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">Low OCR Confidence</div>
|
||||
<div class="text-xs">Please select the correct puzzle manually</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<select
|
||||
v-model="file.manualPuzzleSelection"
|
||||
class="select select-bordered select-sm w-full"
|
||||
@change="onManualPuzzleSelection(file)"
|
||||
>
|
||||
<option value="">Select puzzle...</option>
|
||||
<option
|
||||
v-for="puzzle in puzzlesStore.puzzles"
|
||||
:key="puzzle.id"
|
||||
:value="puzzle.title"
|
||||
>
|
||||
{{ puzzle.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual OCR trigger for non-auto detected files -->
|
||||
<div v-else-if="!file.ocrProcessing && !file.ocrError && !file.ocrData" class="mt-1">
|
||||
<button
|
||||
@@ -335,6 +362,15 @@ const processOCR = async (submissionFile: SubmissionFile) => {
|
||||
// Force reactivity update
|
||||
await nextTick()
|
||||
files.value[fileIndex].ocrData = ocrData
|
||||
|
||||
// Check if puzzle confidence is below 80% and needs manual selection
|
||||
if (ocrData.confidence.puzzle < 0.8) {
|
||||
files.value[fileIndex].needsManualPuzzleSelection = true
|
||||
console.log(`Low puzzle confidence (${Math.round(ocrData.confidence.puzzle * 100)}%) for ${submissionFile.file.name}, requiring manual selection`)
|
||||
} else {
|
||||
files.value[fileIndex].needsManualPuzzleSelection = false
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
} catch (error) {
|
||||
console.error('OCR processing failed:', error)
|
||||
@@ -353,4 +389,16 @@ const getConfidenceBadgeClass = (confidence: number): string => {
|
||||
if (confidence >= 0.6) return 'badge-warning'
|
||||
return 'badge-error'
|
||||
}
|
||||
|
||||
const onManualPuzzleSelection = (submissionFile: SubmissionFile) => {
|
||||
// Find the file in the reactive array
|
||||
const fileIndex = files.value.findIndex(f => f.file === submissionFile.file)
|
||||
if (fileIndex === -1) return
|
||||
|
||||
// Clear the manual selection requirement once user has selected
|
||||
if (files.value[fileIndex].manualPuzzleSelection) {
|
||||
files.value[fileIndex].needsManualPuzzleSelection = false
|
||||
console.log(`Manual puzzle selection: ${submissionFile.file.name} -> ${files.value[fileIndex].manualPuzzleSelection}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -24,6 +24,18 @@
|
||||
<!-- File Upload -->
|
||||
<FileUpload v-model="submissionFiles" :puzzles="puzzles" />
|
||||
|
||||
<!-- Manual Selection Warning -->
|
||||
<div v-if="filesNeedingManualSelection.length > 0" class="alert alert-warning">
|
||||
<i class="mdi mdi-alert-circle text-xl"></i>
|
||||
<div class="flex-1">
|
||||
<div class="font-bold">Manual Puzzle Selection Required</div>
|
||||
<div class="text-sm">
|
||||
{{ filesNeedingManualSelection.length }} file(s) have low OCR confidence for puzzle names.
|
||||
Please select the correct puzzle for each file before submitting.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="form-control">
|
||||
<div class="flex-1">
|
||||
@@ -64,10 +76,14 @@
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="isSubmitting"
|
||||
:disabled="!canSubmit"
|
||||
>
|
||||
<span v-if="isSubmitting" class="loading loading-spinner loading-sm"></span>
|
||||
{{ isSubmitting ? 'Submitting...' : 'Submit Solution' }}
|
||||
<span v-if="isSubmitting">Submitting...</span>
|
||||
<span v-else-if="filesNeedingManualSelection.length > 0">
|
||||
Select Puzzles ({{ filesNeedingManualSelection.length }} remaining)
|
||||
</span>
|
||||
<span v-else>Submit Solution</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -100,8 +116,12 @@ const isSubmitting = ref(false)
|
||||
const notesLength = computed(() => notes.value.length)
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return submissionFiles.value.length > 0 &&
|
||||
!isSubmitting.value
|
||||
const hasFiles = submissionFiles.value.length > 0
|
||||
const noManualSelectionNeeded = !submissionFiles.value.some(file => file.needsManualPuzzleSelection)
|
||||
|
||||
return hasFiles &&
|
||||
!isSubmitting.value &&
|
||||
noManualSelectionNeeded
|
||||
})
|
||||
|
||||
// Group files by detected puzzle
|
||||
@@ -109,8 +129,10 @@ const responsesByPuzzle = computed(() => {
|
||||
const grouped: Record<string, { puzzle: SteamCollectionItem | null, files: SubmissionFile[] }> = {}
|
||||
|
||||
submissionFiles.value.forEach(file => {
|
||||
if (file.ocrData?.puzzle) {
|
||||
const puzzleName = file.ocrData.puzzle
|
||||
// Use manual puzzle selection if available, otherwise fall back to OCR
|
||||
const puzzleName = file.manualPuzzleSelection || file.ocrData?.puzzle
|
||||
|
||||
if (puzzleName) {
|
||||
if (!grouped[puzzleName]) {
|
||||
grouped[puzzleName] = {
|
||||
puzzle: props.findPuzzleByName(puzzleName),
|
||||
@@ -124,6 +146,11 @@ const responsesByPuzzle = computed(() => {
|
||||
return grouped
|
||||
})
|
||||
|
||||
// Count files that need manual puzzle selection
|
||||
const filesNeedingManualSelection = computed(() => {
|
||||
return submissionFiles.value.filter(file => file.needsManualPuzzleSelection)
|
||||
})
|
||||
|
||||
// Check if any OCR confidence is below 50%
|
||||
const hasLowConfidence = computed(() => {
|
||||
return submissionFiles.value.some(file => {
|
||||
|
||||
@@ -238,8 +238,10 @@ export const submissionHelpers = {
|
||||
}> = {}
|
||||
|
||||
files.forEach(file => {
|
||||
if (file.ocrData?.puzzle) {
|
||||
const puzzleName = file.ocrData.puzzle
|
||||
// Use manual puzzle selection if available, otherwise fall back to OCR
|
||||
const puzzleName = file.manualPuzzleSelection || file.ocrData?.puzzle
|
||||
|
||||
if (puzzleName) {
|
||||
if (!responsesByPuzzle[puzzleName]) {
|
||||
responsesByPuzzle[puzzleName] = {
|
||||
puzzle: puzzleHelpers.findPuzzleByName(puzzles, puzzleName),
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface SubmissionFile {
|
||||
ocrProcessing?: boolean
|
||||
ocrError?: string
|
||||
original_filename?: string
|
||||
manualPuzzleSelection?: string
|
||||
needsManualPuzzleSelection?: boolean
|
||||
}
|
||||
|
||||
export interface PuzzleResponse {
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -16,12 +16,12 @@
|
||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
|
||||
},
|
||||
"src/main.ts": {
|
||||
"file": "assets/main-fzs-6OUY.js",
|
||||
"file": "assets/main-B14l8Jy0.js",
|
||||
"name": "main",
|
||||
"src": "src/main.ts",
|
||||
"isEntry": true,
|
||||
"css": [
|
||||
"assets/main-DeQiP-Az.css"
|
||||
"assets/main-COx9N9qO.css"
|
||||
],
|
||||
"assets": [
|
||||
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
||||
|
||||
@@ -2,8 +2,12 @@ from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.utils import timezone
|
||||
from .models import (
|
||||
SteamAPIKey, SteamCollection, SteamCollectionItem,
|
||||
Submission, PuzzleResponse, SubmissionFile
|
||||
SteamAPIKey,
|
||||
SteamCollection,
|
||||
SteamCollectionItem,
|
||||
Submission,
|
||||
PuzzleResponse,
|
||||
SubmissionFile,
|
||||
)
|
||||
|
||||
|
||||
@@ -151,7 +155,14 @@ class SubmissionFileInline(admin.TabularInline):
|
||||
model = SubmissionFile
|
||||
extra = 0
|
||||
readonly_fields = ["file_size", "content_type", "ocr_processed", "created_at"]
|
||||
fields = ["file", "original_filename", "file_size", "content_type", "ocr_processed", "ocr_error"]
|
||||
fields = [
|
||||
"file",
|
||||
"original_filename",
|
||||
"file_size",
|
||||
"content_type",
|
||||
"ocr_processed",
|
||||
"ocr_error",
|
||||
]
|
||||
|
||||
|
||||
class PuzzleResponseInline(admin.TabularInline):
|
||||
@@ -159,39 +170,69 @@ class PuzzleResponseInline(admin.TabularInline):
|
||||
extra = 0
|
||||
readonly_fields = ["created_at", "updated_at"]
|
||||
fields = [
|
||||
"puzzle", "puzzle_name", "cost", "cycles", "area",
|
||||
"needs_manual_validation", "ocr_confidence_cost", "ocr_confidence_cycles", "ocr_confidence_area"
|
||||
"puzzle",
|
||||
"puzzle_name",
|
||||
"cost",
|
||||
"cycles",
|
||||
"area",
|
||||
"needs_manual_validation",
|
||||
"ocr_confidence_cost",
|
||||
"ocr_confidence_cycles",
|
||||
"ocr_confidence_area",
|
||||
]
|
||||
|
||||
|
||||
@admin.register(Submission)
|
||||
class SubmissionAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id", "user", "total_responses", "needs_validation",
|
||||
"manual_validation_requested", "is_validated", "created_at"
|
||||
"id",
|
||||
"user",
|
||||
"total_responses",
|
||||
"needs_validation",
|
||||
"manual_validation_requested",
|
||||
"is_validated",
|
||||
"created_at",
|
||||
]
|
||||
list_filter = [
|
||||
"is_validated", "manual_validation_requested", "created_at", "updated_at"
|
||||
"is_validated",
|
||||
"manual_validation_requested",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
search_fields = ["id", "user__username", "notes"]
|
||||
readonly_fields = ["id", "created_at", "updated_at", "total_responses", "needs_validation"]
|
||||
readonly_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"total_responses",
|
||||
"needs_validation",
|
||||
]
|
||||
inlines = [PuzzleResponseInline]
|
||||
|
||||
fieldsets = (
|
||||
("Basic Information", {
|
||||
"fields": ("id", "user", "notes")
|
||||
}),
|
||||
("Validation", {
|
||||
"fields": ("manual_validation_requested", "is_validated", "validated_by", "validated_at")
|
||||
}),
|
||||
("Statistics", {
|
||||
"fields": ("total_responses", "needs_validation"),
|
||||
"classes": ("collapse",)
|
||||
}),
|
||||
("Timestamps", {
|
||||
"fields": ("created_at", "updated_at"),
|
||||
"classes": ("collapse",)
|
||||
}),
|
||||
("Basic Information", {"fields": ("id", "user", "notes")}),
|
||||
(
|
||||
"Validation",
|
||||
{
|
||||
"fields": (
|
||||
"manual_validation_requested",
|
||||
"is_validated",
|
||||
"validated_by",
|
||||
"validated_at",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Statistics",
|
||||
{
|
||||
"fields": ("total_responses", "needs_validation"),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Timestamps",
|
||||
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
|
||||
),
|
||||
)
|
||||
|
||||
actions = ["mark_as_validated"]
|
||||
@@ -217,36 +258,57 @@ class SubmissionAdmin(admin.ModelAdmin):
|
||||
@admin.register(PuzzleResponse)
|
||||
class PuzzleResponseAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"puzzle_name", "submission", "puzzle", "cost", "cycles", "area",
|
||||
"needs_manual_validation", "created_at"
|
||||
]
|
||||
list_filter = [
|
||||
"needs_manual_validation", "puzzle__collection", "created_at"
|
||||
"puzzle_name",
|
||||
"submission",
|
||||
"puzzle",
|
||||
"cost",
|
||||
"cycles",
|
||||
"area",
|
||||
"needs_manual_validation",
|
||||
"created_at",
|
||||
]
|
||||
list_filter = ["needs_manual_validation", "puzzle__collection", "created_at"]
|
||||
search_fields = [
|
||||
"puzzle_name", "submission__id", "puzzle__title",
|
||||
"cost", "cycles", "area"
|
||||
"puzzle_name",
|
||||
"submission__id",
|
||||
"puzzle__title",
|
||||
"cost",
|
||||
"cycles",
|
||||
"area",
|
||||
]
|
||||
readonly_fields = ["created_at", "updated_at"]
|
||||
inlines = [SubmissionFileInline]
|
||||
|
||||
fieldsets = (
|
||||
("Basic Information", {
|
||||
"fields": ("submission", "puzzle", "puzzle_name")
|
||||
}),
|
||||
("OCR Data", {
|
||||
"fields": ("cost", "cycles", "area", "ocr_confidence_cost", "ocr_confidence_cycles", "ocr_confidence_area")
|
||||
}),
|
||||
("Validation", {
|
||||
"fields": (
|
||||
"needs_manual_validation",
|
||||
"validated_cost", "validated_cycles", "validated_area"
|
||||
)
|
||||
}),
|
||||
("Timestamps", {
|
||||
"fields": ("created_at", "updated_at"),
|
||||
"classes": ("collapse",)
|
||||
}),
|
||||
("Basic Information", {"fields": ("submission", "puzzle", "puzzle_name")}),
|
||||
(
|
||||
"OCR Data",
|
||||
{
|
||||
"fields": (
|
||||
"cost",
|
||||
"cycles",
|
||||
"area",
|
||||
"ocr_confidence_cost",
|
||||
"ocr_confidence_cycles",
|
||||
"ocr_confidence_area",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Validation",
|
||||
{
|
||||
"fields": (
|
||||
"needs_manual_validation",
|
||||
"validated_cost",
|
||||
"validated_cycles",
|
||||
"validated_area",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Timestamps",
|
||||
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
|
||||
),
|
||||
)
|
||||
|
||||
actions = ["mark_for_validation", "clear_validation_flag"]
|
||||
@@ -259,7 +321,9 @@ class PuzzleResponseAdmin(admin.ModelAdmin):
|
||||
def clear_validation_flag(self, request, queryset):
|
||||
"""Clear validation flag for selected responses"""
|
||||
updated = queryset.update(needs_manual_validation=False)
|
||||
self.message_user(request, f"{updated} responses cleared from validation queue.")
|
||||
self.message_user(
|
||||
request, f"{updated} responses cleared from validation queue."
|
||||
)
|
||||
|
||||
mark_for_validation.short_description = "Mark as needing validation"
|
||||
clear_validation_flag.short_description = "Clear validation flag"
|
||||
@@ -268,35 +332,47 @@ class PuzzleResponseAdmin(admin.ModelAdmin):
|
||||
@admin.register(SubmissionFile)
|
||||
class SubmissionFileAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"original_filename", "response", "file_size_display",
|
||||
"content_type", "ocr_processed", "created_at"
|
||||
]
|
||||
list_filter = [
|
||||
"content_type", "ocr_processed", "created_at"
|
||||
"original_filename",
|
||||
"response",
|
||||
"file_size_display",
|
||||
"content_type",
|
||||
"ocr_processed",
|
||||
"created_at",
|
||||
]
|
||||
list_filter = ["content_type", "ocr_processed", "created_at"]
|
||||
search_fields = [
|
||||
"original_filename", "response__puzzle_name",
|
||||
"response__submission__id"
|
||||
"original_filename",
|
||||
"response__puzzle_name",
|
||||
"response__submission__id",
|
||||
]
|
||||
readonly_fields = [
|
||||
"file_size", "content_type", "ocr_processed",
|
||||
"created_at", "updated_at", "file_url"
|
||||
"file_size",
|
||||
"content_type",
|
||||
"ocr_processed",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"file_url",
|
||||
]
|
||||
|
||||
fieldsets = (
|
||||
("File Information", {
|
||||
"fields": ("file", "original_filename", "file_size", "content_type", "file_url")
|
||||
}),
|
||||
("OCR Processing", {
|
||||
"fields": ("ocr_processed", "ocr_raw_data", "ocr_error")
|
||||
}),
|
||||
("Relationships", {
|
||||
"fields": ("response",)
|
||||
}),
|
||||
("Timestamps", {
|
||||
"fields": ("created_at", "updated_at"),
|
||||
"classes": ("collapse",)
|
||||
}),
|
||||
(
|
||||
"File Information",
|
||||
{
|
||||
"fields": (
|
||||
"file",
|
||||
"original_filename",
|
||||
"file_size",
|
||||
"content_type",
|
||||
"file_url",
|
||||
)
|
||||
},
|
||||
),
|
||||
("OCR Processing", {"fields": ("ocr_processed", "ocr_raw_data", "ocr_error")}),
|
||||
("Relationships", {"fields": ("response",)}),
|
||||
(
|
||||
"Timestamps",
|
||||
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
|
||||
),
|
||||
)
|
||||
|
||||
def file_size_display(self, obj):
|
||||
|
||||
@@ -69,9 +69,18 @@ def create_submission(
|
||||
with transaction.atomic():
|
||||
# Check if any confidence score is below 50% to auto-request validation
|
||||
auto_request_validation = any(
|
||||
(response_data.ocr_confidence_cost is not None and response_data.ocr_confidence_cost < 0.5) or
|
||||
(response_data.ocr_confidence_cycles is not None and response_data.ocr_confidence_cycles < 0.5) or
|
||||
(response_data.ocr_confidence_area is not None and response_data.ocr_confidence_area < 0.5)
|
||||
(
|
||||
response_data.ocr_confidence_cost is not None
|
||||
and response_data.ocr_confidence_cost < 0.5
|
||||
)
|
||||
or (
|
||||
response_data.ocr_confidence_cycles is not None
|
||||
and response_data.ocr_confidence_cycles < 0.5
|
||||
)
|
||||
or (
|
||||
response_data.ocr_confidence_area is not None
|
||||
and response_data.ocr_confidence_area < 0.5
|
||||
)
|
||||
for response_data in data.responses
|
||||
)
|
||||
|
||||
@@ -79,7 +88,8 @@ def create_submission(
|
||||
submission = Submission.objects.create(
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
notes=data.notes,
|
||||
manual_validation_requested=data.manual_validation_requested or auto_request_validation,
|
||||
manual_validation_requested=data.manual_validation_requested
|
||||
or auto_request_validation,
|
||||
)
|
||||
|
||||
file_index = 0
|
||||
@@ -100,7 +110,7 @@ def create_submission(
|
||||
cost=response_data.cost,
|
||||
cycles=response_data.cycles,
|
||||
area=response_data.area,
|
||||
needs_manual_validation=response_data.needs_manual_validation,
|
||||
needs_manual_validation=data.manual_validation_requested,
|
||||
ocr_confidence_cost=response_data.ocr_confidence_cost,
|
||||
ocr_confidence_cycles=response_data.ocr_confidence_cycles,
|
||||
ocr_confidence_area=response_data.ocr_confidence_area,
|
||||
|
||||
@@ -2,5 +2,5 @@ from django.apps import AppConfig
|
||||
|
||||
|
||||
class SubmissionsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'submissions'
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "submissions"
|
||||
|
||||
@@ -8,40 +8,39 @@ from submissions.models import SteamCollection
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Fetch Steam Workshop collection data and save to database'
|
||||
help = "Fetch Steam Workshop collection data and save to database"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("url", type=str, help="Steam Workshop collection URL")
|
||||
parser.add_argument(
|
||||
'url',
|
||||
"--api-key",
|
||||
type=str,
|
||||
help='Steam Workshop collection URL'
|
||||
help="Steam API key (optional, can also be set via STEAM_API_KEY environment variable)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--api-key',
|
||||
type=str,
|
||||
help='Steam API key (optional, can also be set via STEAM_API_KEY environment variable)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--force',
|
||||
action='store_true',
|
||||
help='Force refetch even if collection already exists'
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Force refetch even if collection already exists",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
url = options['url']
|
||||
api_key = options.get('api_key')
|
||||
force = options['force']
|
||||
url = options["url"]
|
||||
api_key = options.get("api_key")
|
||||
force = options["force"]
|
||||
|
||||
self.stdout.write(f"Fetching Steam collection from: {url}")
|
||||
|
||||
try:
|
||||
# Check if collection already exists
|
||||
from submissions.utils import SteamCollectionFetcher
|
||||
|
||||
fetcher = SteamCollectionFetcher(api_key)
|
||||
collection_id = fetcher.extract_collection_id(url)
|
||||
|
||||
if collection_id and not force:
|
||||
existing = SteamCollection.objects.filter(steam_id=collection_id).first()
|
||||
existing = SteamCollection.objects.filter(
|
||||
steam_id=collection_id
|
||||
).first()
|
||||
if existing:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
@@ -72,7 +71,9 @@ class Command(BaseCommand):
|
||||
self.stdout.write(f" Steam ID: {collection.steam_id}")
|
||||
self.stdout.write(f" Title: {collection.title}")
|
||||
self.stdout.write(f" Author: {collection.author_name or 'Unknown'}")
|
||||
self.stdout.write(f" Description: {collection.description[:100]}{'...' if len(collection.description) > 100 else ''}")
|
||||
self.stdout.write(
|
||||
f" Description: {collection.description[:100]}{'...' if len(collection.description) > 100 else ''}"
|
||||
)
|
||||
self.stdout.write(f" Total Items: {collection.total_items}")
|
||||
self.stdout.write(f" Unique Visitors: {collection.unique_visitors}")
|
||||
self.stdout.write(f" Current Favorites: {collection.current_favorites}")
|
||||
@@ -81,10 +82,14 @@ class Command(BaseCommand):
|
||||
if collection.items.exists():
|
||||
self.stdout.write(f"\nCollection Items ({collection.items.count()}):")
|
||||
for item in collection.items.all()[:10]: # Show first 10 items
|
||||
self.stdout.write(f" - {item.title} (Steam ID: {item.steam_item_id})")
|
||||
self.stdout.write(
|
||||
f" - {item.title} (Steam ID: {item.steam_item_id})"
|
||||
)
|
||||
|
||||
if collection.items.count() > 10:
|
||||
self.stdout.write(f" ... and {collection.items.count() - 10} more items")
|
||||
self.stdout.write(
|
||||
f" ... and {collection.items.count() - 10} more items"
|
||||
)
|
||||
else:
|
||||
self.stdout.write("\nNo items found in collection.")
|
||||
|
||||
|
||||
@@ -5,68 +5,215 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Collection',
|
||||
name="Collection",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('url', models.URLField()),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("url", models.URLField()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SteamCollection',
|
||||
name="SteamCollection",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('steam_id', models.CharField(help_text='Steam collection ID from URL', max_length=50, unique=True)),
|
||||
('url', models.URLField(help_text='Full Steam Workshop collection URL')),
|
||||
('title', models.CharField(blank=True, help_text='Collection title', max_length=255)),
|
||||
('description', models.TextField(blank=True, help_text='Collection description')),
|
||||
('author_name', models.CharField(blank=True, help_text='Steam username of collection creator', max_length=100)),
|
||||
('author_steam_id', models.CharField(blank=True, help_text='Steam ID of collection creator', max_length=50)),
|
||||
('total_items', models.PositiveIntegerField(default=0, help_text='Number of items in collection')),
|
||||
('unique_visitors', models.PositiveIntegerField(default=0, help_text='Number of unique visitors')),
|
||||
('current_favorites', models.PositiveIntegerField(default=0, help_text='Current number of favorites')),
|
||||
('total_favorites', models.PositiveIntegerField(default=0, help_text='Total unique favorites')),
|
||||
('steam_created_date', models.DateTimeField(blank=True, help_text='When collection was created on Steam', null=True)),
|
||||
('steam_updated_date', models.DateTimeField(blank=True, help_text='When collection was last updated on Steam', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('last_fetched', models.DateTimeField(blank=True, help_text='When data was last fetched from Steam', null=True)),
|
||||
('is_active', models.BooleanField(default=True, help_text='Whether this collection is actively tracked')),
|
||||
('fetch_error', models.TextField(blank=True, help_text='Last error encountered when fetching data')),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"steam_id",
|
||||
models.CharField(
|
||||
help_text="Steam collection ID from URL",
|
||||
max_length=50,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"url",
|
||||
models.URLField(help_text="Full Steam Workshop collection URL"),
|
||||
),
|
||||
(
|
||||
"title",
|
||||
models.CharField(
|
||||
blank=True, help_text="Collection title", max_length=255
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, help_text="Collection description"),
|
||||
),
|
||||
(
|
||||
"author_name",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Steam username of collection creator",
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
(
|
||||
"author_steam_id",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Steam ID of collection creator",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
(
|
||||
"total_items",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Number of items in collection"
|
||||
),
|
||||
),
|
||||
(
|
||||
"unique_visitors",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Number of unique visitors"
|
||||
),
|
||||
),
|
||||
(
|
||||
"current_favorites",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Current number of favorites"
|
||||
),
|
||||
),
|
||||
(
|
||||
"total_favorites",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Total unique favorites"
|
||||
),
|
||||
),
|
||||
(
|
||||
"steam_created_date",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="When collection was created on Steam",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"steam_updated_date",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="When collection was last updated on Steam",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"last_fetched",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="When data was last fetched from Steam",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether this collection is actively tracked",
|
||||
),
|
||||
),
|
||||
(
|
||||
"fetch_error",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Last error encountered when fetching data",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Steam Collection',
|
||||
'verbose_name_plural': 'Steam Collections',
|
||||
'ordering': ['-created_at'],
|
||||
"verbose_name": "Steam Collection",
|
||||
"verbose_name_plural": "Steam Collections",
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SteamCollectionItem',
|
||||
name="SteamCollectionItem",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('steam_item_id', models.CharField(help_text='Steam Workshop item ID', max_length=50)),
|
||||
('title', models.CharField(blank=True, help_text='Item title', max_length=255)),
|
||||
('author_name', models.CharField(blank=True, help_text='Steam username of item creator', max_length=100)),
|
||||
('author_steam_id', models.CharField(blank=True, help_text='Steam ID of item creator', max_length=50)),
|
||||
('description', models.TextField(blank=True, help_text='Item description')),
|
||||
('tags', models.JSONField(blank=True, default=list, help_text='Item tags as JSON array')),
|
||||
('order_index', models.PositiveIntegerField(default=0, help_text='Order of item in collection')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='submissions.steamcollection')),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"steam_item_id",
|
||||
models.CharField(help_text="Steam Workshop item ID", max_length=50),
|
||||
),
|
||||
(
|
||||
"title",
|
||||
models.CharField(
|
||||
blank=True, help_text="Item title", max_length=255
|
||||
),
|
||||
),
|
||||
(
|
||||
"author_name",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Steam username of item creator",
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
(
|
||||
"author_steam_id",
|
||||
models.CharField(
|
||||
blank=True, help_text="Steam ID of item creator", max_length=50
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, help_text="Item description"),
|
||||
),
|
||||
(
|
||||
"tags",
|
||||
models.JSONField(
|
||||
blank=True, default=list, help_text="Item tags as JSON array"
|
||||
),
|
||||
),
|
||||
(
|
||||
"order_index",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Order of item in collection"
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"collection",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="items",
|
||||
to="submissions.steamcollection",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Steam Collection Item',
|
||||
'verbose_name_plural': 'Steam Collection Items',
|
||||
'ordering': ['collection', 'order_index'],
|
||||
'unique_together': {('collection', 'steam_item_id')},
|
||||
"verbose_name": "Steam Collection Item",
|
||||
"verbose_name_plural": "Steam Collection Items",
|
||||
"ordering": ["collection", "order_index"],
|
||||
"unique_together": {("collection", "steam_item_id")},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,13 +4,12 @@ from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('submissions', '0001_initial'),
|
||||
("submissions", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='Collection',
|
||||
name="Collection",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,28 +4,66 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('submissions', '0002_delete_collection'),
|
||||
("submissions", "0002_delete_collection"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SteamAPIKey',
|
||||
name="SteamAPIKey",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text="Descriptive name for this API key (e.g., 'Production Key', 'Development Key')", max_length=100, unique=True)),
|
||||
('api_key', models.CharField(help_text='Steam Web API key from https://steamcommunity.com/dev/apikey', max_length=64)),
|
||||
('is_active', models.BooleanField(default=True, help_text='Whether this API key should be used')),
|
||||
('description', models.TextField(blank=True, help_text='Optional description or notes about this API key')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('last_used', models.DateTimeField(blank=True, help_text='When this API key was last used', null=True)),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(
|
||||
help_text="Descriptive name for this API key (e.g., 'Production Key', 'Development Key')",
|
||||
max_length=100,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"api_key",
|
||||
models.CharField(
|
||||
help_text="Steam Web API key from https://steamcommunity.com/dev/apikey",
|
||||
max_length=64,
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True, help_text="Whether this API key should be used"
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Optional description or notes about this API key",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"last_used",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="When this API key was last used",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Steam API Key',
|
||||
'verbose_name_plural': 'Steam API Keys',
|
||||
'ordering': ['-is_active', 'name'],
|
||||
"verbose_name": "Steam API Key",
|
||||
"verbose_name_plural": "Steam API Keys",
|
||||
"ordering": ["-is_active", "name"],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
+218
-48
@@ -8,75 +8,245 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('submissions', '0003_steamapikey'),
|
||||
("submissions", "0003_steamapikey"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Submission',
|
||||
name="Submission",
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('notes', models.TextField(blank=True, help_text='Optional notes about the submission')),
|
||||
('is_validated', models.BooleanField(default=False, help_text='Whether this submission has been manually validated')),
|
||||
('validated_at', models.DateTimeField(blank=True, help_text='When this submission was validated', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(blank=True, help_text='User who made the submission (null for anonymous)', null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('validated_by', models.ForeignKey(blank=True, help_text='Admin user who validated this submission', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='validated_submissions', to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"notes",
|
||||
models.TextField(
|
||||
blank=True, help_text="Optional notes about the submission"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_validated",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this submission has been manually validated",
|
||||
),
|
||||
),
|
||||
(
|
||||
"validated_at",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="When this submission was validated",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="User who made the submission (null for anonymous)",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"validated_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Admin user who validated this submission",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="validated_submissions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Submission',
|
||||
'verbose_name_plural': 'Submissions',
|
||||
'ordering': ['-created_at'],
|
||||
"verbose_name": "Submission",
|
||||
"verbose_name_plural": "Submissions",
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PuzzleResponse',
|
||||
name="PuzzleResponse",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('puzzle_name', models.CharField(help_text='Puzzle name as detected by OCR', max_length=255)),
|
||||
('cost', models.CharField(blank=True, help_text='Cost value from OCR', max_length=20)),
|
||||
('cycles', models.CharField(blank=True, help_text='Cycles value from OCR', max_length=20)),
|
||||
('area', models.CharField(blank=True, help_text='Area value from OCR', max_length=20)),
|
||||
('needs_manual_validation', models.BooleanField(default=False, help_text='Whether OCR failed and manual validation is needed')),
|
||||
('ocr_confidence_score', models.FloatField(blank=True, help_text='OCR confidence score (0.0 to 1.0)', null=True)),
|
||||
('validated_cost', models.CharField(blank=True, help_text='Manually validated cost value', max_length=20)),
|
||||
('validated_cycles', models.CharField(blank=True, help_text='Manually validated cycles value', max_length=20)),
|
||||
('validated_area', models.CharField(blank=True, help_text='Manually validated area value', max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('puzzle', models.ForeignKey(help_text='The puzzle this response is for', on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='submissions.steamcollectionitem')),
|
||||
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='submissions.submission')),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"puzzle_name",
|
||||
models.CharField(
|
||||
help_text="Puzzle name as detected by OCR", max_length=255
|
||||
),
|
||||
),
|
||||
(
|
||||
"cost",
|
||||
models.CharField(
|
||||
blank=True, help_text="Cost value from OCR", max_length=20
|
||||
),
|
||||
),
|
||||
(
|
||||
"cycles",
|
||||
models.CharField(
|
||||
blank=True, help_text="Cycles value from OCR", max_length=20
|
||||
),
|
||||
),
|
||||
(
|
||||
"area",
|
||||
models.CharField(
|
||||
blank=True, help_text="Area value from OCR", max_length=20
|
||||
),
|
||||
),
|
||||
(
|
||||
"needs_manual_validation",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether OCR failed and manual validation is needed",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ocr_confidence_score",
|
||||
models.FloatField(
|
||||
blank=True,
|
||||
help_text="OCR confidence score (0.0 to 1.0)",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"validated_cost",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Manually validated cost value",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"validated_cycles",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Manually validated cycles value",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"validated_area",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Manually validated area value",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"puzzle",
|
||||
models.ForeignKey(
|
||||
help_text="The puzzle this response is for",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="responses",
|
||||
to="submissions.steamcollectionitem",
|
||||
),
|
||||
),
|
||||
(
|
||||
"submission",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="responses",
|
||||
to="submissions.submission",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Puzzle Response',
|
||||
'verbose_name_plural': 'Puzzle Responses',
|
||||
'ordering': ['submission', 'puzzle__order_index'],
|
||||
'unique_together': {('submission', 'puzzle')},
|
||||
"verbose_name": "Puzzle Response",
|
||||
"verbose_name_plural": "Puzzle Responses",
|
||||
"ordering": ["submission", "puzzle__order_index"],
|
||||
"unique_together": {("submission", "puzzle")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SubmissionFile',
|
||||
name="SubmissionFile",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('file', models.FileField(help_text='Uploaded file (image/gif)', upload_to=submissions.models.submission_file_upload_path)),
|
||||
('original_filename', models.CharField(help_text='Original filename as uploaded by user', max_length=255)),
|
||||
('file_size', models.PositiveIntegerField(help_text='File size in bytes')),
|
||||
('content_type', models.CharField(help_text='MIME type of the file', max_length=100)),
|
||||
('ocr_processed', models.BooleanField(default=False, help_text='Whether OCR has been processed for this file')),
|
||||
('ocr_raw_data', models.JSONField(blank=True, help_text='Raw OCR data as JSON', null=True)),
|
||||
('ocr_error', models.TextField(blank=True, help_text='OCR processing error message')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('response', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='submissions.puzzleresponse')),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"file",
|
||||
models.FileField(
|
||||
help_text="Uploaded file (image/gif)",
|
||||
upload_to=submissions.models.submission_file_upload_path,
|
||||
),
|
||||
),
|
||||
(
|
||||
"original_filename",
|
||||
models.CharField(
|
||||
help_text="Original filename as uploaded by user",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
(
|
||||
"file_size",
|
||||
models.PositiveIntegerField(help_text="File size in bytes"),
|
||||
),
|
||||
(
|
||||
"content_type",
|
||||
models.CharField(help_text="MIME type of the file", max_length=100),
|
||||
),
|
||||
(
|
||||
"ocr_processed",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether OCR has been processed for this file",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ocr_raw_data",
|
||||
models.JSONField(
|
||||
blank=True, help_text="Raw OCR data as JSON", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"ocr_error",
|
||||
models.TextField(
|
||||
blank=True, help_text="OCR processing error message"
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"response",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="files",
|
||||
to="submissions.puzzleresponse",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Submission File',
|
||||
'verbose_name_plural': 'Submission Files',
|
||||
'ordering': ['response', 'created_at'],
|
||||
"verbose_name": "Submission File",
|
||||
"verbose_name_plural": "Submission Files",
|
||||
"ordering": ["response", "created_at"],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,15 +4,16 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('submissions', '0004_submission_puzzleresponse_submissionfile'),
|
||||
("submissions", "0004_submission_puzzleresponse_submissionfile"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='submission',
|
||||
name='notes',
|
||||
field=models.TextField(blank=True, help_text='Optional notes about the submission', null=True),
|
||||
model_name="submission",
|
||||
name="notes",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="Optional notes about the submission", null=True
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
+24
-13
@@ -4,29 +4,40 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('submissions', '0005_alter_submission_notes'),
|
||||
("submissions", "0005_alter_submission_notes"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='puzzleresponse',
|
||||
name='ocr_confidence_score',
|
||||
model_name="puzzleresponse",
|
||||
name="ocr_confidence_score",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='puzzleresponse',
|
||||
name='ocr_confidence_area',
|
||||
field=models.FloatField(blank=True, help_text='OCR confidence score for area (0.0 to 1.0)', null=True),
|
||||
model_name="puzzleresponse",
|
||||
name="ocr_confidence_area",
|
||||
field=models.FloatField(
|
||||
blank=True,
|
||||
help_text="OCR confidence score for area (0.0 to 1.0)",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='puzzleresponse',
|
||||
name='ocr_confidence_cost',
|
||||
field=models.FloatField(blank=True, help_text='OCR confidence score for cost (0.0 to 1.0)', null=True),
|
||||
model_name="puzzleresponse",
|
||||
name="ocr_confidence_cost",
|
||||
field=models.FloatField(
|
||||
blank=True,
|
||||
help_text="OCR confidence score for cost (0.0 to 1.0)",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='puzzleresponse',
|
||||
name='ocr_confidence_cycles',
|
||||
field=models.FloatField(blank=True, help_text='OCR confidence score for cycles (0.0 to 1.0)', null=True),
|
||||
model_name="puzzleresponse",
|
||||
name="ocr_confidence_cycles",
|
||||
field=models.FloatField(
|
||||
blank=True,
|
||||
help_text="OCR confidence score for cycles (0.0 to 1.0)",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
+7
-5
@@ -4,15 +4,17 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('submissions', '0006_remove_puzzleresponse_ocr_confidence_score_and_more'),
|
||||
("submissions", "0006_remove_puzzleresponse_ocr_confidence_score_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='submission',
|
||||
name='manual_validation_requested',
|
||||
field=models.BooleanField(default=False, help_text='Whether the user specifically requested manual validation'),
|
||||
model_name="submission",
|
||||
name="manual_validation_requested",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether the user specifically requested manual validation",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
import uuid
|
||||
import os
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -246,7 +244,7 @@ class Submission(models.Model):
|
||||
# Manual validation request
|
||||
manual_validation_requested = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether the user specifically requested manual validation"
|
||||
help_text="Whether the user specifically requested manual validation",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from ninja import Schema, ModelSchema, File
|
||||
from ninja.files import UploadedFile
|
||||
from ninja import Schema, ModelSchema
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
|
||||
@@ -4,6 +4,7 @@ Utilities for fetching Steam Workshop collection data using Steam Web API
|
||||
|
||||
import re
|
||||
import requests
|
||||
from submissions.models import SteamCollection
|
||||
from datetime import datetime
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
@@ -20,7 +21,11 @@ class SteamAPIClient:
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
# Priority: parameter > database > settings > environment
|
||||
self.api_key = api_key or self._get_api_key_from_db() or getattr(settings, "STEAM_API_KEY", None)
|
||||
self.api_key = (
|
||||
api_key
|
||||
or self._get_api_key_from_db()
|
||||
or getattr(settings, "STEAM_API_KEY", None)
|
||||
)
|
||||
self.session = requests.Session()
|
||||
|
||||
if not self.api_key:
|
||||
@@ -30,12 +35,14 @@ class SteamAPIClient:
|
||||
"""Get active API key from database"""
|
||||
try:
|
||||
from .models import SteamAPIKey
|
||||
|
||||
api_key_obj = SteamAPIKey.get_active_key()
|
||||
if api_key_obj:
|
||||
# Update last_used timestamp
|
||||
from django.utils import timezone
|
||||
|
||||
api_key_obj.last_used = timezone.now()
|
||||
api_key_obj.save(update_fields=['last_used'])
|
||||
api_key_obj.save(update_fields=["last_used"])
|
||||
return api_key_obj.api_key
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not fetch API key from database: {e}")
|
||||
@@ -246,30 +253,34 @@ class SteamCollectionFetcher:
|
||||
try:
|
||||
# Use GetCollectionDetails API to get collection items
|
||||
url = f"{self.api_client.BASE_URL}/ISteamRemoteStorage/GetCollectionDetails/v1/"
|
||||
data = {
|
||||
'collectioncount': 1,
|
||||
'publishedfileids[0]': collection_id
|
||||
}
|
||||
data = {"collectioncount": 1, "publishedfileids[0]": collection_id}
|
||||
|
||||
response = self.api_client.session.post(url, data=data, timeout=30)
|
||||
if response.status_code == 200:
|
||||
collection_response = response.json()
|
||||
|
||||
if 'response' in collection_response and 'collectiondetails' in collection_response['response']:
|
||||
for collection in collection_response['response']['collectiondetails']:
|
||||
if collection.get('result') == 1 and 'children' in collection:
|
||||
if (
|
||||
"response" in collection_response
|
||||
and "collectiondetails" in collection_response["response"]
|
||||
):
|
||||
for collection in collection_response["response"][
|
||||
"collectiondetails"
|
||||
]:
|
||||
if collection.get("result") == 1 and "children" in collection:
|
||||
# Extract item IDs with their sort order
|
||||
child_items = []
|
||||
for child in collection['children']:
|
||||
if 'publishedfileid' in child:
|
||||
child_items.append({
|
||||
'id': str(child['publishedfileid']),
|
||||
'sort_order': child.get('sortorder', 0)
|
||||
})
|
||||
for child in collection["children"]:
|
||||
if "publishedfileid" in child:
|
||||
child_items.append(
|
||||
{
|
||||
"id": str(child["publishedfileid"]),
|
||||
"sort_order": child.get("sortorder", 0),
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by sort order to maintain collection order
|
||||
child_items.sort(key=lambda x: x['sort_order'])
|
||||
item_ids = [item['id'] for item in child_items]
|
||||
child_items.sort(key=lambda x: x["sort_order"])
|
||||
item_ids = [item["id"] for item in child_items]
|
||||
|
||||
if item_ids:
|
||||
items = self._fetch_items_by_ids(item_ids)
|
||||
@@ -333,7 +344,9 @@ class SteamCollectionFetcher:
|
||||
items.append(item_info)
|
||||
else:
|
||||
# Log failed items
|
||||
logger.warning(f"Failed to fetch item {item_id}: result={result}, ban_reason={item_data.get('ban_reason', 'N/A')}")
|
||||
logger.warning(
|
||||
f"Failed to fetch item {item_id}: result={result}, ban_reason={item_data.get('ban_reason', 'N/A')}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch batch of collection items: {e}")
|
||||
@@ -342,7 +355,6 @@ class SteamCollectionFetcher:
|
||||
return items
|
||||
|
||||
|
||||
|
||||
def fetch_steam_collection(url: str) -> Dict:
|
||||
"""
|
||||
Convenience function to fetch Steam collection data
|
||||
@@ -357,7 +369,7 @@ def fetch_steam_collection(url: str) -> Dict:
|
||||
return fetcher.fetch_collection_data(url)
|
||||
|
||||
|
||||
def create_or_update_collection(url: str) -> Tuple["SteamCollection", bool]:
|
||||
def create_or_update_collection(url: str) -> Tuple[SteamCollection, bool]:
|
||||
"""
|
||||
Create or update a Steam collection in the database
|
||||
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
|
||||
Reference in New Issue
Block a user