Compare commits

..

8 Commits

9 changed files with 343 additions and 345 deletions
@@ -0,0 +1,30 @@
"""add date_of_birth column to users table
Revision ID: 0002_add_date_of_birth_to_users
Revises: 0001_initial
Create Date: 2025-01-01 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0002_add_date_of_birth_to_users"
down_revision: Union[str, None] = "0001_initial"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add date_of_birth column to users table."""
op.add_column(
"users",
sa.Column("date_of_birth", sa.Date(), nullable=True),
)
def downgrade() -> None:
"""Remove date_of_birth column from users table."""
op.drop_column("users", "date_of_birth")
+17 -1
View File
@@ -1,4 +1,7 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from datetime import date
from typing import Optional
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
@@ -12,6 +15,7 @@ class User(Base):
username = Column(String(50), unique=True, index=True, nullable=False)
email = Column(String(120), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
date_of_birth = Column(Date, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
@@ -19,3 +23,15 @@ class User(Base):
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner")
@property
def is_adult(self) -> bool:
"""Return True if the user is 18 or older based on date_of_birth."""
if self.date_of_birth is None:
return False
today = date.today()
age = today.year - self.date_of_birth.year
# Adjust if birthday hasn't occurred yet this year
if (today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day):
age -= 1
return age >= 18
+49 -3
View File
@@ -1,10 +1,10 @@
"""Authentication router - login, register, token refresh."""
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, EmailStr, field_validator
from jose import jwt
from passlib.context import CryptContext
@@ -32,12 +32,24 @@ class RegisterRequest(BaseModel):
username: str
email: EmailStr
password: str
date_of_birth: str # ISO format date string (YYYY-MM-DD)
@field_validator("date_of_birth")
@classmethod
def validate_date_of_birth(cls, v: str) -> str:
"""Validate that the date string is a valid date in YYYY-MM-DD format."""
try:
datetime.strptime(v, "%Y-%m-%d")
except ValueError:
raise ValueError("date_of_birth must be in YYYY-MM-DD format")
return v
class UserResponse(BaseModel):
id: int
username: str
email: str
date_of_birth: str | None = None
is_active: bool
created_at: datetime | None = None
@@ -65,11 +77,44 @@ def hash_password(password: str) -> str:
return pwd_context.hash(password)
def calculate_age(birth_date: date, today: date | None = None) -> int:
"""Calculate age in years from a birth date.
Args:
birth_date: The person's date of birth.
today: The reference date. Defaults to today's date (UTC).
Returns:
Age in whole years as of the reference date.
"""
if today is None:
today = datetime.now(timezone.utc).date()
age = today.year - birth_date.year
# Adjust if birthday hasn't occurred yet this year
if (today.month, today.day) < (birth_date.month, birth_date.day):
age -= 1
return age
# --- Endpoints ---
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""Register a new user and return an access token."""
"""Register a new user and return an access token.
Requires date_of_birth and rejects users under 18.
"""
# Parse and validate date_of_birth
dob = datetime.strptime(req.date_of_birth, "%Y-%m-%d").date()
age = calculate_age(dob)
if age < 18:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Must be 18 or older to register (provided date of birth: {req.date_of_birth})",
)
# Check if username already exists
result = await db.execute(select(User).where(User.username == req.username))
if result.scalar_one_or_none():
@@ -90,6 +135,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
username=req.username,
email=req.email,
hashed_password=hash_password(req.password),
date_of_birth=dob,
)
db.add(user)
await db.commit()
-1
View File
@@ -1 +0,0 @@
"""Application services."""
-340
View File
@@ -1,340 +0,0 @@
"""Pluggable server-side text filtering service.
Provides a ``TextFilter`` protocol with a deterministic default
implementation (profanity/term list with normalization) and a hook
for third-party providers.
Usage::
from app.services.text_filter import TextFilter, FilterResult, \
ProfanityTextFilter, get_text_filter
# Default factory reads config from the path below
filt = get_text_filter()
result = filt.check("Hello wor1d!")
# result.action == FilterAction.ALLOW
"""
from __future__ import annotations
import json
import os
import re
import unicodedata
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any
# ---------------------------------------------------------------------------
# Enums / Data classes
# ---------------------------------------------------------------------------
class FilterAction(str, Enum):
"""Action the filter wants the caller to take."""
ALLOW = "allow"
REDACT = "redact"
REJECT = "reject"
@dataclass(frozen=True)
class FilterResult:
"""Immutable result of a single ``TextFilter.check()`` call.
Attributes:
action: What to do with the message.
filtered_text: The text after redaction (may equal *text* if
action is ``ALLOW``).
matched_terms: Sorted list of terms that triggered the filter.
"""
action: FilterAction
filtered_text: str
matched_terms: list[str] = field(default_factory=list)
@property
def is_allowed(self) -> bool:
return self.action == FilterAction.ALLOW
@property
def is_rejected(self) -> bool:
return self.action == FilterAction.REJECT
@property
def is_redacted(self) -> bool:
return self.action == FilterAction.REDACT
# ---------------------------------------------------------------------------
# Protocol / abstract base
# ---------------------------------------------------------------------------
class TextFilter(ABC):
"""Protocol for server-side text filters.
Every concrete filter must implement ``check()``. The default
implementation (``ProfanityTextFilter``) is deterministic and
requires no external service. A third-party provider can be
plugged in by subclassing ``TextFilter`` and overriding ``check``.
"""
@abstractmethod
def check(self, text: str) -> FilterResult:
"""Analyse *text* and return a ``FilterResult``.
Args:
text: Raw message text to filter.
Returns:
A ``FilterResult`` with the decision and any redacted text.
"""
# ---------------------------------------------------------------------------
# Normalisation helpers
# ---------------------------------------------------------------------------
# Common leetspeak substitutions (char -> set of possible originals)
_LEETSPEAK: dict[str, str] = {
"0": "o",
"1": "i",
"3": "e",
"4": "a",
"5": "s",
"7": "t",
"8": "b",
"9": "g",
"@": "a",
}
# Normalise separators that are often used to obfuscate words
_SEPARATORS = re.compile(r"[\s_\-\.]+")
def _normalize(text: str) -> str:
"""Case-fold, strip diacritics, and replace leetspeak / separators.
This produces a canonical form for matching against the term list.
"""
# Case-fold
text = text.casefold()
# Strip diacritics (e.g. é -> e)
text = unicodedata.normalize("NFKD", text)
text = "".join(c for c in text if not unicodedata.combining(c))
# Replace leetspeak
normalized = ""
for ch in text:
normalized += _LEETSPEAK.get(ch, ch)
# Collapse separators
normalized = _SEPARATORS.sub("", normalized)
return normalized
# ---------------------------------------------------------------------------
# Default implementation
# ---------------------------------------------------------------------------
# Default config path (relative to project root)
_DEFAULT_CONFIG_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "text_filter_config.json"
@dataclass
class _FilterPolicy:
"""Filtering policy loaded from config."""
mode: str # "redact" or "reject"
redaction_char: str = "*"
# Minimum term length to consider (avoids matching single chars)
min_term_length: int = 3
# If True, match terms case-insensitively after normalisation
case_insensitive: bool = True
# If True, match whole words only (after normalisation)
whole_word: bool = True
class ProfanityTextFilter(TextFilter):
"""Deterministic text filter backed by a JSON term list.
The config file is a JSON object with:
.. code-block:: json
{
"mode": "redact",
"redaction_char": "*",
"min_term_length": 3,
"case_insensitive": true,
"whole_word": true,
"terms": ["badword", "another", ...]
}
Terms are matched after normalisation (case-fold, leetspeak
substitution, separator removal, diacritic stripping).
"""
def __init__(
self,
config_path: str | Path | None = None,
terms: list[str] | None = None,
policy: _FilterPolicy | None = None,
) -> None:
"""
Args:
config_path: Path to a JSON config file. Ignored if *terms*
is provided.
terms: Inline list of terms (overrides config file).
policy: Filter policy. Defaults to a redact policy.
"""
if terms is not None:
self._terms: list[str] = [t.lower() for t in terms]
elif config_path is not None:
self._terms = self._load_terms(Path(config_path))
else:
# Try default path
if _DEFAULT_CONFIG_PATH.exists():
self._terms = self._load_terms(_DEFAULT_CONFIG_PATH)
else:
self._terms = []
self._policy = policy or _FilterPolicy(mode="redact")
# Pre-compile patterns for each term
self._patterns: list[tuple[re.Pattern[str], str]] = []
for term in self._terms:
if len(term) < self._policy.min_term_length:
continue
if self._policy.whole_word:
pattern = re.compile(
r"(?<!\w)" + re.escape(term) + r"(?!\w)",
re.IGNORECASE if self._policy.case_insensitive else 0,
)
else:
pattern = re.compile(
re.escape(term),
re.IGNORECASE if self._policy.case_insensitive else 0,
)
self._patterns.append((pattern, term))
# -- config loading --------------------------------------------------
@staticmethod
def _load_terms(config_path: Path) -> list[str]:
"""Load terms from a JSON config file."""
with open(config_path, "r", encoding="utf-8") as f:
data: dict[str, Any] = json.load(f)
terms: list[str] = []
for raw in data.get("terms", []):
term = str(raw).strip()
if term:
terms.append(term.casefold())
return terms
# -- public API ------------------------------------------------------
def check(self, text: str) -> FilterResult:
"""Check *text* against the loaded term list.
Returns a ``FilterResult`` with the appropriate action,
filtered text, and matched terms.
"""
if not text:
return FilterResult(
action=FilterAction.ALLOW,
filtered_text=text,
matched_terms=[],
)
normalized = _normalize(text)
matched_terms: list[str] = []
# Collect all matches
for pattern, term in self._patterns:
if pattern.search(normalized):
if term not in matched_terms:
matched_terms.append(term)
if not matched_terms:
return FilterResult(
action=FilterAction.ALLOW,
filtered_text=text,
matched_terms=[],
)
matched_terms.sort()
# Build filtered text by replacing matched terms
filtered = text
for term in matched_terms:
norm_term = _normalize(term)
# Find all occurrences in the normalised text and replace in original
# We need to map positions from normalised text back to original
filtered = self._replace_terms(filtered, term, norm_term)
action = FilterAction.REJECT if self._policy.mode == "reject" else FilterAction.REDACT
return FilterResult(
action=action,
filtered_text=filtered,
matched_terms=matched_terms,
)
def _replace_terms(
self, text: str, term: str, norm_term: str
) -> str:
"""Replace occurrences of *term* in *text* with redaction chars.
Uses normalised text for matching but preserves original casing
in the output.
"""
norm_text = _normalize(text)
norm_term_len = len(norm_term)
result_chars = list(text)
start = 0
while start < len(norm_text):
idx = norm_text.find(norm_term, start)
if idx == -1:
break
# Replace characters in original text
for i in range(idx, min(idx + norm_term_len, len(result_chars))):
result_chars[i] = self._policy.redaction_char
start = idx + norm_term_len
return "".join(result_chars)
@property
def terms(self) -> list[str]:
"""Read-only access to the loaded term list."""
return list(self._terms)
@property
def policy(self) -> _FilterPolicy:
"""Read-only access to the filter policy."""
return self._policy
# ---------------------------------------------------------------------------
# Factory
# ---------------------------------------------------------------------------
def get_text_filter(
config_path: str | Path | None = None,
terms: list[str] | None = None,
policy: _FilterPolicy | None = None,
) -> TextFilter:
"""Return a ``TextFilter`` instance.
By default this creates a ``ProfanityTextFilter``. To use a
third-party provider, subclass ``TextFilter`` and pass an
instance here instead.
"""
return ProfanityTextFilter(
config_path=config_path,
terms=terms,
policy=policy,
)
+3
View File
@@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
testpaths = tests
View File
+94
View File
@@ -0,0 +1,94 @@
"""Tests for the 18+ age gate on user registration."""
from datetime import date, timedelta
import pytest
from app.routers.auth import calculate_age
class TestCalculateAge:
"""Unit tests for the calculate_age helper."""
def test_exactly_18(self):
"""A person born exactly 18 years ago today is 18."""
today = date(2025, 6, 15)
dob = today.replace(year=today.year - 18)
assert calculate_age(dob, today=today) == 18
def test_just_turned_18(self):
"""A person who turned 18 yesterday is 18."""
today = date(2025, 6, 15)
dob = today.replace(year=today.year - 18) - timedelta(days=1)
assert calculate_age(dob, today=today) == 18
def test_one_day_short_of_18(self):
"""A person born one day after their 18th birthday is 17."""
today = date(2025, 6, 15)
dob = today.replace(year=today.year - 18) + timedelta(days=1)
assert calculate_age(dob, today=today) == 17
def test_17_years_old(self):
"""A person who is 17 years old today is 17."""
today = date(2025, 6, 15)
dob = date(2008, 6, 15)
assert calculate_age(dob, today=today) == 17
def test_65_years_old(self):
"""A person who is 65 is correctly calculated."""
today = date(2025, 6, 15)
dob = today.replace(year=today.year - 65)
assert calculate_age(dob, today=today) == 65
def test_newborn(self):
"""A newborn is 0."""
today = date(2025, 6, 15)
dob = today
assert calculate_age(dob, today=today) == 0
def test_birthday_not_yet_this_year(self):
"""Person born Dec 31, 2000 — on Jan 1, 2025 they are still 24."""
dob = date(2000, 12, 31)
assert calculate_age(dob, today=date(2025, 1, 1)) == 24
def test_birthday_today(self):
"""Person born Dec 31, 2000 — on Dec 31, 2024 they turn 24."""
dob = date(2000, 12, 31)
assert calculate_age(dob, today=date(2024, 12, 31)) == 24
class TestRegisterRequestDateValidation:
"""Tests for the RegisterRequest date_of_birth field validator."""
@pytest.mark.parametrize(
"dob",
[
"2000-01-01",
"1990-12-31",
"2004-02-29", # leap day
],
)
def test_valid_dates(self, dob):
"""Valid YYYY-MM-DD dates pass validation."""
from app.routers.auth import RegisterRequest
req = RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
assert req.date_of_birth == dob
@pytest.mark.parametrize(
"dob",
[
"01-01-2000",
"2000/01/01",
"2000-13-01",
"2000-00-10",
"not-a-date",
"",
],
)
def test_invalid_dates_rejected(self, dob):
"""Invalid dates are rejected by the validator."""
from app.routers.auth import RegisterRequest
with pytest.raises(ValueError):
RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
+150
View File
@@ -0,0 +1,150 @@
# Discovery — Browse, Search & Home — Plan
> **Status:** Planned
> **Owner:** Platform team
> **Feature ID:** `discovery`
> **Godot version:** 4.6.2 stable
> **Backend:** FastAPI + SQLAlchemy (async) + PostgreSQL + Redis
## 1. Overview
This document breaks the **Discovery** feature into user stories and
right-sized implementation tasks. The feature delivers:
- **Home / discover page** — a default listing of experiences a player lands on.
- **Search** — find worlds by name and by tag.
- **Categories** — a single category per world plus free-form tags, with
filtering.
- **Simple sorting** — newest, most-played, and trending. Trending starts as a
basic **recent-activity heuristic** (weighted recent visits); no real
recommendation model for MVP.
Without this, nothing else built (experiences, creator content) is actually
reachable by a real user. Discovery is the front door.
## 2. Why this matters
The platform's value is *reachable* content. Worlds and creator assets already
exist in the data model (`World`, `Asset`), but there is currently:
- **No public listing** of worlds (`/api/worlds` requires auth and returns only
PUBLIC worlds or your own worlds, ordered by `created_at` only).
- **No search** by name or tag.
- **No category/tag metadata** on worlds at all.
- **No play-count or recency signal** to power "most-played" or "trending".
- **No client UI** for browsing.
Discovery closes that gap with a small, dependency-free metadata layer plus a
read-optimized listing endpoint.
## 3. Existing constraints (do not re-build)
- **Auth:** JWT bearer tokens; `get_current_user` / `CurrentUser` dependency in
`app/dependencies.py`. Discovery **listing** must work both for anonymous
visitors and authenticated users, so it needs an *optional* auth dependency
(not `CurrentUser`, which returns 401).
- **DB:** async SQLAlchemy + Alembic; migration head is `0002_add_date_of_birth_to_users`.
- **Models:** `World` (id, name, description, owner_id, visibility, created_at,
updated_at). `User`, `Asset` (owner_id, is_public) exist. No tags/categories/
play-count fields yet.
- **Routers:** `worlds.py` already owns CRUD under `/api/worlds`. Discovery adds
a new read-focused router rather than overloading it.
- **Redis:** configured (`REDIS_URL`) but unused; **not required** for discovery
MVP (Postgres queries are sufficient at this scale).
- **Client:** Godot 4.6.2, minimal `main_menu.gd`; no networking client, no
discovery UI.
## 4. Key design decisions
| Concern | Decision |
|---|---|
| Listing transport | REST (`GET /api/discovery/worlds`) — no WebSocket needed for browse |
| Anonymous browsing | Listing/read endpoints accept **optional** auth; visibility filtering treats anonymous users as "PUBLIC only" |
| Categories | Single `category` string column on `worlds` (indexed). Free-form for MVP; a distinct-categories list is derived with a `SELECT DISTINCT` |
| Tags | PostgreSQL `ARRAY(String)` column on `worlds` with a GIN index for `@>` tag containment search |
| Play count | `play_count` integer (default 0) on `worlds`, incremented atomically on visit |
| Recency signal | `last_played_at` timestamp on `worlds` + append-only `world_visits` table for the trending heuristic |
| Trending (MVP) | Weighted score over recent visits: `sum(decay_weight(age))` over the last N days, no ML |
| Sorting | `newest` (created_at desc), `most_played` (play_count desc), `trending` (score desc) |
| Pagination | `skip`/`limit` (limit capped at 100), consistent with existing routers |
| Visibility | Discovery only surfaces `PUBLIC` worlds; never leaks private/friends_only worlds |
## 5. User stories
### US-1 Home / discover feed
As a player, I can open a home/discover page and see a default list of
experiences without searching.
**Acceptance criteria**
- Returns PUBLIC worlds only, newest-first by default.
- Paginated with a stable total count.
- Works anonymously and authenticated.
- Each result includes id, name, description, category, tags, owner, play_count,
and created_at.
### US-2 Search
As a player, I can search experiences by name and by tag.
**Acceptance criteria**
- Case-insensitive substring search on `name`.
- Tag search matches worlds containing *any* of the requested tags.
- Search combines with category filter and sort.
### US-3 Categories & sorting
As a player, I can filter by category and sort by newest, most-played, or
trending.
**Acceptance criteria**
- `category` filter matches exactly (case-insensitive).
- Distinct category list is available for UI dropdowns.
- `newest`, `most_played`, and `trending` sorts are supported.
- `most_played` uses `play_count`; `trending` uses the recent-visits heuristic.
### US-4 Visit tracking
As a player, opening a world records a visit so counts and trending stay fresh.
**Acceptance criteria**
- `POST /api/worlds/{id}/visit` increments `play_count` and updates
`last_played_at`.
- Appends a `world_visits` row (world_id, user_id or anonymous marker,
visited_at).
- Idempotent per request; rate-limitable later.
### US-5 Godot client
As a player, I can browse, search, filter, and sort experiences from the game UI.
**Acceptance criteria**
- An HTTP API client handles the discovery endpoints (JWT + anonymous).
- A discover/home screen lists experiences with search, category filter, and
sort controls.
- Results render name, category, tags, play count, and description.
## 6. Task breakdown
Right-sized to 1–3 files / one coherent piece of functionality each.
| # | Task | Story | Depends on |
|---|------|-------|-----------|
| 1 | World discovery metadata + visits model & Alembic migration | US-2, US-3, US-4 | — |
| 2 | World visit tracking endpoint (play count + last played + visit log) | US-4 | 1 |
| 3 | Browse + search endpoint (name/tag/category, newest + most-played) | US-1, US-2, US-3 | 1, 2 |
| 4 | Trending sort (recent-activity heuristic over `world_visits`) | US-3 | 3 |
| 5 | Godot discovery API client (HTTP + optional JWT) | US-5 | 3 |
| 6 | Godot discover/home UI (feed, search, category filter, sort) | US-5 | 4, 5 |
## 7. Phasing
- **Phase 1 (data + core read API):** tasks 1–3 (metadata, visit tracking,
browse/search).
- **Phase 2 (trending):** task 4 — the only non-trivial query; isolated so it
can be tuned without touching the browse surface.
- **Phase 3 (client):** tasks 5–6, each landing after its backend dependency.
## 8. Out of scope for this feature
- Real recommendation models / personalization (collaborative filtering, ML).
- Full-text search (Postgres FTS); MVP uses ILIKE substring match.
- Faceted search with multiple simultaneous category/tag AND-semantics.
- Featured/editorial curation and admin-curated carousels.
- Search result relevance scoring beyond the three explicit sorts.
- Analytics dashboards; `world_visits` is append-only signal, not a BI surface.