Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Tests and lint: `make test`, `make lint`. CI runs the same on every push.

- [x] **M0 Foundation** — this. Repo, settings split, four apps, health endpoint, route skeleton, CI, deploy configs.
- [x] **M1 Content backbone** — models, Django Admin (Unfold), Cedar Hollow fixture, read-only endpoints, printable QR sheet.
- [ ] **M2 Scan loop** — guest sessions, `POST /scan`, discovery XP, marker landing, Success, Profile.
- [x] **M2 Scan loop** — guest sessions, `POST /scan`, discovery XP once per animal, level-ups, marker landing, Welcome, Success, Profile, Choose Adventure, in-app scanner.
- [ ] **M3 Quests and challenges** — six verifiers, submit/hint, quest screens.
- [ ] **M4 Progression** — levels, badge rule engine, full Success sequence, Map.
- [ ] **M5 Hardening** — service worker, retries, throttling, analytics view.
Expand Down
62 changes: 61 additions & 1 deletion apps/api/apps/play/admin.py
Original file line number Diff line number Diff line change
@@ -1 +1,61 @@
# Admin registrations arrive in M1.
"""Read-mostly views of play data for debugging and support. Analytics proper arrives in M5."""

from django.contrib import admin
from unfold.admin import ModelAdmin, TabularInline

from apps.tenants.admin import ZooScopedAdmin

from .models import Discovery, GuestSession, Scan, XPEvent


class DiscoveryInline(TabularInline):
model = Discovery
fields = ["animal", "marker", "discovered_at"]
readonly_fields = fields
extra = 0
can_delete = False


class XPEventInline(TabularInline):
model = XPEvent
fields = ["amount", "source_type", "source_id", "created_at"]
readonly_fields = fields
extra = 0
can_delete = False


@admin.register(GuestSession)
class GuestSessionAdmin(ZooScopedAdmin):
list_display = [
"__str__",
"zoo",
"total_xp",
"discovery_count",
"scan_count",
"created_at",
"last_seen_at",
]
list_filter = ["zoo", "created_at"]
search_fields = ["team_name", "token"]
readonly_fields = ["token", "total_xp", "created_at", "last_seen_at", "user_agent"]
inlines = [DiscoveryInline, XPEventInline]
date_hierarchy = "created_at"

@admin.display(description="Discovered")
def discovery_count(self, obj):
return obj.discoveries.count()

@admin.display(description="Scans")
def scan_count(self, obj):
return obj.scans.count()


@admin.register(Scan)
class ScanAdmin(ModelAdmin):
list_display = ["scanned_at", "session", "marker", "result", "xp_awarded"]
list_filter = ["result", "marker__zoo", "marker__exhibit"]
readonly_fields = ["session", "marker", "result", "xp_awarded", "scanned_at"]
date_hierarchy = "scanned_at"

def has_add_permission(self, request):
return False
65 changes: 65 additions & 0 deletions apps/api/apps/play/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""
Guest identity. The explorer app sends `X-Guest-Token: <uuid>`; we resolve it
to a GuestSession and expose it as request.guest. No Django User is involved.
"""

import uuid

from drf_spectacular.extensions import OpenApiAuthenticationExtension
from rest_framework import authentication, exceptions, permissions

from .models import GuestSession

HEADER = "HTTP_X_GUEST_TOKEN"


class GuestUser:
"""Minimal stand-in so DRF's request.user machinery is satisfied."""

is_authenticated = True
is_anonymous = False
is_active = True

def __init__(self, session):
self.session = session
self.pk = session.pk # DRF throttling keys on user.pk

def __str__(self):
return f"guest:{self.session.pk}"


class GuestTokenAuthentication(authentication.BaseAuthentication):
def authenticate(self, request):
raw = request.META.get(HEADER)
if not raw:
return None
try:
token = uuid.UUID(raw.strip())
except ValueError:
raise exceptions.AuthenticationFailed("That explorer token is not valid.") from None
session = GuestSession.objects.select_related("zoo").filter(token=token, zoo__is_active=True).first()
if session is None:
raise exceptions.AuthenticationFailed("We could not find your adventure. Start a new one.")
session.save(update_fields=["last_seen_at"]) # heartbeat for session-length analytics
request.guest = session
return (GuestUser(session), token)

def authenticate_header(self, request):
return "X-Guest-Token"


class IsGuest(permissions.BasePermission):
message = "Start an adventure first."

def has_permission(self, request, view):
return getattr(request, "guest", None) is not None


class GuestTokenScheme(OpenApiAuthenticationExtension):
"""Tells drf-spectacular (and the generated TypeScript client) about the header."""

target_class = "apps.play.auth.GuestTokenAuthentication"
name = "guestToken"

def get_security_definition(self, auto_schema):
return {"type": "apiKey", "in": "header", "name": "X-Guest-Token"}
Loading
Loading