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
+23 -17
View File
@@ -25,11 +25,13 @@ from .schemas import NoitaSubmissionOut
router = Router()
NOITA_APP_ID = 881100
PREFIX = "/events/{event_path}/noita"
@router.get("results", response=ResultsOut)
@router.get(f"{PREFIX}/results", response=ResultsOut)
@decorate_view(require_game_enabled(NOITA_APP_ID))
def get_results(request: HttpRequest):
cache_key = f"api:noita:results:{request.user.id}"
def get_results(request: HttpRequest, event_path: str):
cache_key = f"api:{event_path}:noita:results:{request.user.id}"
cached_data = cache.get(cache_key)
if cached_data is not None:
@@ -42,7 +44,7 @@ def get_results(request: HttpRequest):
"""
# Group objectives by objectiv_id and count occurrences
user_objectives = (
Objectiv.objects.filter(user=request.user)
Objectiv.objects.filter(user=request.user, submission__event=request.event)
.values("objectiv_id")
.annotate(count=Count("id"))
)
@@ -118,7 +120,7 @@ def get_results(request: HttpRequest):
total_score += points
# Count deaths for the user
deaths_count = DeathCounter.objects.filter(user=request.user).count()
deaths_count = DeathCounter.for_event.filter(user=request.user).count()
data = {
"total_score": total_score,
@@ -126,19 +128,19 @@ def get_results(request: HttpRequest):
"objectives": list(with_points.values()),
}
cache.set(f"api:noita:results:{request.user.id}", data, 300)
cache.set(cache_key, data, 300)
return data
@router.get("leaderboard", response=LeaderboardOut)
@router.get(f"{PREFIX}/leaderboard", response=LeaderboardOut)
@decorate_view(require_game_enabled(NOITA_APP_ID))
def get_leaderboard(request: HttpRequest):
def get_leaderboard(request: HttpRequest, event_path: str):
"""
Get the global leaderboard for all users ranked by total score.
Uses Window functions to rank users by their total score in descending order.
"""
cache_key = "api:noita:leaderboard"
cache_key = f"api:{event_path}:noita:leaderboard"
cached_data = cache.get(cache_key)
if cached_data is not None:
@@ -150,7 +152,8 @@ def get_leaderboard(request: HttpRequest):
# Get all objectives with calculated points (grouped by objectiv_id and user)
all_objectives = (
Objectiv.objects.values("user", "objectiv_id")
Objectiv.objects.filter(submission__event=request.event)
.values("user", "objectiv_id")
.annotate(count=Count("id"))
.annotate(
# Fetch points from ObjectivPoint using Subquery
@@ -201,12 +204,12 @@ def get_leaderboard(request: HttpRequest):
for user_id, total_score in user_totals_dict.items():
user = User.objects.get(id=user_id)
objectives_count = (
Objectiv.objects.filter(user_id=user_id)
Objectiv.objects.filter(user_id=user_id, submission__event=request.event)
.values("objectiv_id")
.distinct()
.count()
)
deaths_count = DeathCounter.objects.filter(user_id=user_id).count()
deaths_count = DeathCounter.for_event.filter(user_id=user_id).count()
users_with_scores.append(
{
"user_id": user_id,
@@ -232,13 +235,15 @@ def get_leaderboard(request: HttpRequest):
]
data = {"leaderboard": leaderboard}
cache.set("api:noita:leaderboard", data, 300)
cache.set(cache_key, data, 300)
return data
@router.post("submit", response={200: NoitaSubmissionOut, 400: dict})
@router.post(f"{PREFIX}/submit", response={200: NoitaSubmissionOut, 400: dict})
@decorate_view(require_game_enabled(NOITA_APP_ID))
def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
def submit_log_file(
request: HttpRequest, event_path: str, file: UploadedFile = File(...)
):
"""
Submit a Noita run file (log file, screenshot, or video).
@@ -267,6 +272,7 @@ def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
try:
# Create submission
submission = LogfileSubmission.objects.create(
event=request.event,
user=request.user if request.user.is_authenticated else None,
content_type=file.content_type,
file_size=file.size,
@@ -284,8 +290,8 @@ def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
# Invalidate caches on successful submission
if submission.user:
cache.delete(f"api:noita:results:{submission.user.id}")
cache.delete("api:noita:leaderboard")
cache.delete(f"api:{event_path}:noita:results:{submission.user.id}")
cache.delete(f"api:{event_path}:noita:leaderboard")
return {
"id": str(submission.id),
@@ -0,0 +1,34 @@
# Generated by Django 6.0.5 on 2026-09-15 21:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("events", "0002_seed_polylan_43"),
("noita", "0012_deathcounter_unique_death_per_seen_at"),
]
operations = [
migrations.AddField(
model_name="deathcounter",
name="event",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="death_counters",
to="events.event",
),
),
migrations.AddField(
model_name="logfilesubmission",
name="event",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="logfile_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")
LogfileSubmission = apps.get_model("noita", "LogfileSubmission")
DeathCounter = apps.get_model("noita", "DeathCounter")
event = Event.objects.get(path=EVENT_PATH)
LogfileSubmission.objects.filter(event__isnull=True).update(event=event)
DeathCounter.objects.filter(event__isnull=True).update(event=event)
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("noita", "0013_deathcounter_event_logfilesubmission_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 = [
("noita", "0014_backfill_event"),
]
operations = [
migrations.AlterField(
model_name="deathcounter",
name="event",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="death_counters",
to="events.event",
),
),
migrations.AlterField(
model_name="logfilesubmission",
name="event",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="logfile_submissions",
to="events.event",
),
),
]
+20
View File
@@ -4,6 +4,9 @@ from django.utils import timezone
import uuid
from events.models import Event
from events.scoping import EventScopedManager
User = get_user_model()
@@ -21,6 +24,12 @@ class LogfileSubmission(models.Model):
# Identification
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
event = models.ForeignKey(
Event,
on_delete=models.CASCADE,
related_name="logfile_submissions",
)
content_type = models.CharField(max_length=100, help_text="MIME type of the file")
file_size = models.PositiveIntegerField(help_text="File size in bytes")
file = models.FileField(
@@ -44,6 +53,9 @@ class LogfileSubmission(models.Model):
processed = models.BooleanField(default=False)
objects = models.Manager()
for_event = EventScopedManager()
class Objectiv(models.Model):
objectiv_id = models.CharField(max_length=64)
@@ -71,10 +83,18 @@ class ObjectivPoint(models.Model):
class DeathCounter(models.Model):
event = models.ForeignKey(
Event,
on_delete=models.CASCADE,
related_name="death_counters",
)
user = models.ForeignKey(User, on_delete=models.CASCADE)
seed = models.CharField(max_length=32)
seen_at = models.DateTimeField()
objects = models.Manager()
for_event = EventScopedManager()
class Meta:
constraints = [
models.UniqueConstraint(
@@ -31,7 +31,11 @@ def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
continue
if idx == "DEATH":
deaths.append(DeathCounter(user=logfile.user, seed=seed, seen_at=ts))
deaths.append(
DeathCounter(
event=logfile.event, user=logfile.user, seed=seed, seen_at=ts
)
)
continue
objectives.append(