feat: add events app + "multi-tenants" aka multi-events

This commit is contained in:
2026-09-16 00:03:26 +02:00
parent b5c2cdc037
commit 8b9c254fd2
62 changed files with 1343 additions and 362 deletions
+64 -46
View File
@@ -32,50 +32,51 @@ from .schemas import (
router = Router()
OPUS_MAGNUM_APP_ID = 558990
PREFIX = "/events/{event_path}/opus-magnum"
@router.get("/puzzles", response=List[SteamCollectionItemOut])
@router.get(f"{PREFIX}/puzzles", response=List[SteamCollectionItemOut])
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def list_puzzles(request):
def list_puzzles(request, event_path: str):
"""Get list of available puzzles"""
return SteamCollectionItem.objects.select_related("collection").filter(
collection__is_active=True
collection__is_active=True, collection__event=request.event
)
@router.get("/collection", response=SteamCollectionOut)
@router.get(f"{PREFIX}/collection", response=SteamCollectionOut)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def get_collection(request):
def get_collection(request, event_path: str):
"""Get the active collection details"""
collection = get_object_or_404(SteamCollection, is_active=True)
return collection
return get_object_or_404(SteamCollection.for_event, is_active=True)
@router.get("/submissions", response=List[SubmissionOut])
@router.get(f"{PREFIX}/submissions", response=List[SubmissionOut])
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
@paginate
def list_submissions(request):
def list_submissions(request, event_path: str):
"""Get paginated list of submissions"""
return Submission.objects.prefetch_related(
return Submission.for_event.prefetch_related(
"responses__files", "responses__puzzle"
).filter(user=request.user)
@router.get("/submissions/{submission_id}", response=SubmissionOut)
@router.get(f"{PREFIX}/submissions/{{submission_id}}", response=SubmissionOut)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def get_submission(request, submission_id: str):
def get_submission(request, event_path: str, submission_id: str):
"""Get detailed submission by ID"""
return get_object_or_404(
Submission.objects.prefetch_related(
Submission.for_event.prefetch_related(
"responses__files", "responses__puzzle"
).filter(user=request.user),
id=submission_id,
)
@router.post("/submissions", response=SubmissionOut)
@router.post(f"{PREFIX}/submissions", response=SubmissionOut)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def create_submission(
request, data: SubmissionIn, files: List[UploadedFile] = File(...)
request, event_path: str, data: SubmissionIn, files: List[UploadedFile] = File(...)
):
"""Create a new submission with multiple puzzle responses"""
@@ -92,7 +93,9 @@ def create_submission(
if data.responses:
for puzzle in data.responses:
if not get_object_or_404(
SteamCollectionItem, id=puzzle.puzzle_id
SteamCollectionItem,
id=puzzle.puzzle_id,
collection__event=request.event,
).collection.accepting_submissions:
raise HttpError(
403, "This tournament is no longer accepting submissions"
@@ -119,6 +122,7 @@ def create_submission(
# Create the submission
submission = Submission.objects.create(
event=request.event,
user=request.user if request.user.is_authenticated else None,
notes=data.notes,
manual_validation_requested=data.manual_validation_requested
@@ -129,7 +133,9 @@ def create_submission(
for response_data in data.responses:
# Get the puzzle
puzzle = get_object_or_404(
SteamCollectionItem, id=response_data.puzzle_id
SteamCollectionItem,
id=response_data.puzzle_id,
collection__event=request.event,
)
# Create the puzzle response
@@ -192,12 +198,12 @@ def create_submission(
response.mark_for_validation("Incomplete OCR data")
# Reload with relations for response
submission = Submission.objects.prefetch_related(
submission = Submission.for_event.prefetch_related(
"responses__files", "responses__puzzle"
).get(id=submission.id)
# Invalidate results cache on successful submission
cache.delete("api:results:results")
cache.delete(f"api:{event_path}:results:results")
return submission
@@ -205,18 +211,22 @@ def create_submission(
return 500, {"detail": f"Error creating submission: {str(e)}"}
@router.put("/responses/{response_id}/validate", response=PuzzleResponseOut)
@router.put(f"{PREFIX}/responses/{{response_id}}/validate", response=PuzzleResponseOut)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def validate_response(request, response_id: int, data: ValidationIn):
def validate_response(request, event_path: str, response_id: int, data: ValidationIn):
"""Manually validate a puzzle response"""
if not request.user.is_authenticated or not request.user.is_staff:
return 403, {"detail": "Admin access required"}
response = get_object_or_404(PuzzleResponse, id=response_id)
response = get_object_or_404(
PuzzleResponse, id=response_id, submission__event=request.event
)
if data.puzzle is not None:
puzzle = get_object_or_404(SteamCollectionItem, id=data.puzzle)
puzzle = get_object_or_404(
SteamCollectionItem, id=data.puzzle, collection__event=request.event
)
response.puzzle = puzzle
# Update validated values
@@ -236,20 +246,24 @@ def validate_response(request, response_id: int, data: ValidationIn):
response.save()
# Invalidate results cache when a response is validated
cache.delete("api:results:results")
cache.delete(f"api:{event_path}:results:results")
return response
@router.put("/responses/{response_id}/validate/auto", response=PuzzleResponseOut)
@router.put(
f"{PREFIX}/responses/{{response_id}}/validate/auto", response=PuzzleResponseOut
)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def validate_auto(request, response_id: int):
def validate_auto(request, event_path: str, response_id: int):
"""Try to auto validate a puzzle response"""
if not request.user.is_authenticated or not request.user.is_staff:
return 403, {"detail": "Admin access required"}
response = get_object_or_404(PuzzleResponse, id=response_id)
response = get_object_or_404(
PuzzleResponse, id=response_id, submission__event=request.event
)
for file in response.files.all():
verify_and_validate_ocr_date_for_submission(file)
@@ -257,9 +271,9 @@ def validate_auto(request, response_id: int):
return response
@router.get("/responses/needs-validation", response=List[PuzzleResponseOut])
@router.get(f"{PREFIX}/responses/needs-validation", response=List[PuzzleResponseOut])
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def list_responses_needing_validation(request):
def list_responses_needing_validation(request, event_path: str):
"""Get all responses that need manual validation"""
if not request.user.is_authenticated or not request.user.is_staff:
@@ -267,21 +281,23 @@ def list_responses_needing_validation(request):
return (
PuzzleResponse.objects.filter(needs_manual_validation=True)
.filter(puzzle__collection__is_active=True)
.filter(
puzzle__collection__is_active=True, puzzle__collection__event=request.event
)
.select_related("puzzle", "submission")
.prefetch_related("files")
)
@router.post("/submissions/{submission_id}/validate", response=SubmissionOut)
@router.post(f"{PREFIX}/submissions/{{submission_id}}/validate", response=SubmissionOut)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def validate_submission(request, submission_id: str):
def validate_submission(request, event_path: str, submission_id: str):
"""Mark entire submission as validated"""
if not request.user.is_authenticated or not request.user.is_staff:
return 403, {"detail": "Admin access required"}
submission = get_object_or_404(Submission, id=submission_id)
submission = get_object_or_404(Submission.for_event, id=submission_id)
submission.is_validated = True
submission.validated_by = request.user
@@ -292,44 +308,46 @@ def validate_submission(request, submission_id: str):
submission.responses.update(needs_manual_validation=False)
# Reload with relations
submission = Submission.objects.prefetch_related(
submission = Submission.for_event.prefetch_related(
"responses__files", "responses__puzzle"
).get(id=submission.id)
# Invalidate results cache when submission is validated
cache.delete("api:results:results")
cache.delete(f"api:{event_path}:results:results")
return submission
@router.delete("/submissions/{submission_id}")
@router.delete(f"{PREFIX}/submissions/{{submission_id}}")
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def delete_submission(request, submission_id: str):
def delete_submission(request, event_path: str, submission_id: str):
"""Delete a submission (admin only)"""
if not request.user.is_authenticated or not request.user.is_staff:
return 403, {"detail": "Admin access required"}
submission = get_object_or_404(Submission, id=submission_id)
submission = get_object_or_404(Submission.for_event, id=submission_id)
submission.delete()
# Invalidate results cache when submission is deleted
cache.delete("api:results:results")
cache.delete(f"api:{event_path}:results:results")
return {"detail": "Submission deleted successfully"}
@router.get("/stats")
@router.get(f"{PREFIX}/stats")
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def get_stats(request):
def get_stats(request, event_path: str):
"""Get submission statistics"""
total_submissions = Submission.objects.count()
total_responses = PuzzleResponse.objects.count()
needs_validation = PuzzleResponse.objects.filter(
needs_manual_validation=True
total_submissions = Submission.for_event.count()
total_responses = PuzzleResponse.objects.filter(
submission__event=request.event
).count()
validated_submissions = Submission.objects.filter(is_validated=True).count()
needs_validation = PuzzleResponse.objects.filter(
needs_manual_validation=True, submission__event=request.event
).count()
validated_submissions = Submission.for_event.filter(is_validated=True).count()
return {
"total_submissions": total_submissions,
@@ -3,45 +3,31 @@
from django.db import migrations
TABLE_PAIRS = [
("submissions_steamapikey", "opus_magnum_steamapikey"),
("submissions_steamcollection", "opus_magnum_steamcollection"),
("submissions_steamcollectionitem", "opus_magnum_steamcollectionitem"),
("submissions_submission", "opus_magnum_submission"),
("submissions_puzzleresponse", "opus_magnum_puzzleresponse"),
("submissions_submissionfile", "opus_magnum_submissionfile"),
]
def migrate_data_forward(apps, schema_editor):
"""Copy data from submissions_* tables to opus_magnum_* tables"""
# Use raw SQL to copy data while preserving all fields
"""Copy data from submissions_* tables to opus_magnum_* tables.
The source tables only exist on databases that predate the submissions ->
opus_magnum rename; a database built fresh from this migration history
never has them, so skip pairs whose source table is absent instead of
failing.
"""
existing_tables = set(schema_editor.connection.introspection.table_names())
with schema_editor.connection.cursor() as cursor:
# Copy SteamAPIKey
cursor.execute("""
INSERT INTO opus_magnum_steamapikey
SELECT * FROM submissions_steamapikey
""")
# Copy SteamCollection
cursor.execute("""
INSERT INTO opus_magnum_steamcollection
SELECT * FROM submissions_steamcollection
""")
# Copy SteamCollectionItem
cursor.execute("""
INSERT INTO opus_magnum_steamcollectionitem
SELECT * FROM submissions_steamcollectionitem
""")
# Copy Submission
cursor.execute("""
INSERT INTO opus_magnum_submission
SELECT * FROM submissions_submission
""")
# Copy PuzzleResponse
cursor.execute("""
INSERT INTO opus_magnum_puzzleresponse
SELECT * FROM submissions_puzzleresponse
""")
# Copy SubmissionFile
cursor.execute("""
INSERT INTO opus_magnum_submissionfile
SELECT * FROM submissions_submissionfile
""")
for source, dest in TABLE_PAIRS:
if source not in existing_tables:
continue
cursor.execute(f"INSERT INTO {dest} SELECT * FROM {source}")
def migrate_data_backward(apps, schema_editor):
@@ -0,0 +1,34 @@
# Generated by Django 6.0.5 on 2026-09-15 21:09
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("events", "0002_seed_polylan_43"),
("opus_magnum", "0015_migrate_data_from_submissions"),
]
operations = [
migrations.AddField(
model_name="steamcollection",
name="event",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="steam_collections",
to="events.event",
),
),
migrations.AddField(
model_name="submission",
name="event",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="opus_magnum_submissions",
to="events.event",
),
),
]
@@ -0,0 +1,27 @@
from django.db import migrations
EVENT_PATH = "polylan-43"
def backfill_event(apps, schema_editor):
Event = apps.get_model("events", "Event")
SteamCollection = apps.get_model("opus_magnum", "SteamCollection")
Submission = apps.get_model("opus_magnum", "Submission")
event = Event.objects.get(path=EVENT_PATH)
SteamCollection.objects.filter(event__isnull=True).update(event=event)
Submission.objects.filter(event__isnull=True).update(event=event)
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("opus_magnum", "0016_steamcollection_event_submission_event"),
("events", "0002_seed_polylan_43"),
]
operations = [
migrations.RunPython(backfill_event, noop),
]
@@ -0,0 +1,29 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("opus_magnum", "0017_backfill_event"),
]
operations = [
migrations.AlterField(
model_name="steamcollection",
name="event",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="steam_collections",
to="events.event",
),
),
migrations.AlterField(
model_name="submission",
name="event",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="opus_magnum_submissions",
to="events.event",
),
),
]
+21
View File
@@ -8,6 +8,9 @@ from django.db.models.expressions import Window
from django.db.models.functions import Cast, Rank, RowNumber
from django.db.models.query import F
from events.models import Event
from events.scoping import EventScopedManager
User = get_user_model()
@@ -108,6 +111,12 @@ class SteamAPIKey(models.Model):
class SteamCollection(models.Model):
"""Model representing a Steam Workshop collection"""
event = models.ForeignKey(
Event,
on_delete=models.CASCADE,
related_name="steam_collections",
)
# Basic collection info
steam_id = models.CharField(
max_length=50, unique=True, help_text="Steam collection ID from URL"
@@ -164,6 +173,9 @@ class SteamCollection(models.Model):
blank=True, help_text="Last error encountered when fetching data"
)
objects = models.Manager()
for_event = EventScopedManager()
class Meta:
ordering = ["-created_at"]
verbose_name = "Steam Collection"
@@ -257,6 +269,12 @@ class Submission(models.Model):
# Identification
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
event = models.ForeignKey(
Event,
on_delete=models.CASCADE,
related_name="opus_magnum_submissions",
)
# User information (optional for anonymous submissions)
user = models.ForeignKey(
User,
@@ -299,6 +317,9 @@ class Submission(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects = models.Manager()
for_event = EventScopedManager()
class Meta:
ordering = ["-created_at"]
verbose_name = "Submission"