Compare commits

..

1 Commits

Author SHA1 Message Date
coder-bot dd07baff78 WIP checkpoint: T1: Moderation data model + Alembic migration 2026-08-18 19:07:17 +00:00
9 changed files with 540 additions and 792 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
from app.config import settings from app.config import settings
from app.database import Base from app.database import Base
from app.models import user, session, world, asset # noqa: F401 from app.models import user, session, world, asset, moderation # noqa: F401
# this is the Alembic Config object, which provides # this is the Alembic Config object, which provides
# access to the values within the .ini file in use. # access to the values within the .ini file in use.
@@ -1,36 +0,0 @@
"""add model_gltf value to asset_type enum
Revision ID: 0003_add_gltf_asset_type
Revises: 0002_add_date_of_birth_to_users
Create Date: 2025-06-15 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "0003_add_gltf_asset_type"
down_revision: Union[str, None] = "0002_add_date_of_birth_to_users"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add the 'model_gltf' value to the asset_type enum."""
# IF NOT EXISTS is supported on PostgreSQL 12+; the project targets
# PostgreSQL 16. Adding an enum value and using it in the same
# transaction is restricted until commit, but this migration only adds
# the value so it is safe inside Alembic's transaction.
op.execute("ALTER TYPE asset_type ADD VALUE IF NOT EXISTS 'model_gltf'")
def downgrade() -> None:
"""Remove the 'model_gltf' value from the asset_type enum.
PostgreSQL does not support dropping an enum value without table scans;
this is intentionally a no-op to keep downgrades safe. Re-creating the
enum or deleting rows that use this value would be a destructive action
better performed manually if ever needed.
"""
pass
@@ -0,0 +1,310 @@
"""add moderation schema
Revision ID: 0003_add_moderation_schema
Revises: 0002_add_date_of_birth_to_users
Create Date: 2025-01-01 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0003_add_moderation_schema"
down_revision: Union[str, None] = "0002_add_date_of_birth_to_users"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create moderation tables and extend users table."""
# ── ENUM types ──────────────────────────────────────────────
target_type_enum = postgresql.ENUM(
"user", "world", "asset", "chat_message",
name="target_type",
)
target_type_enum.create(op.get_bind(), checkfirst=True)
reason_category_enum = postgresql.ENUM(
"harassment", "hate_speech", "sexual_content", "violence",
"spam", "self_harm", "other",
name="reason_category",
)
reason_category_enum.create(op.get_bind(), checkfirst=True)
report_status_enum = postgresql.ENUM(
"open", "in_review", "resolved", "dismissed",
name="report_status",
)
report_status_enum.create(op.get_bind(), checkfirst=True)
resolution_enum = postgresql.ENUM(
"upheld", "modified", "dismissed",
name="resolution",
)
resolution_enum.create(op.get_bind(), checkfirst=True)
moderation_action_type_enum = postgresql.ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore",
name="moderation_action_type",
)
moderation_action_type_enum.create(op.get_bind(), checkfirst=True)
appeal_status_enum = postgresql.ENUM(
"pending", "approved", "rejected",
name="appeal_status",
)
appeal_status_enum.create(op.get_bind(), checkfirst=True)
user_role_enum = postgresql.ENUM(
"user", "moderator", "admin",
name="user_role",
)
user_role_enum.create(op.get_bind(), checkfirst=True)
# ── users table extensions ──────────────────────────────────
op.add_column(
"users",
sa.Column(
"role",
postgresql.ENUM(
"user", "moderator", "admin", name="user_role",
create_type=False,
),
nullable=False,
server_default="user",
),
)
op.add_column(
"users",
sa.Column("banned_until", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"users",
sa.Column("suspended_until", sa.DateTime(timezone=True), nullable=True),
)
# ── reports table ───────────────────────────────────────────
op.create_table(
"reports",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("reporter_id", sa.Integer(), nullable=False),
sa.Column(
"target_type",
postgresql.ENUM(
"user", "world", "asset", "chat_message",
name="target_type", create_type=False,
),
nullable=False,
),
sa.Column("target_id", sa.String(length=64), nullable=False),
sa.Column(
"reason_category",
postgresql.ENUM(
"harassment", "hate_speech", "sexual_content", "violence",
"spam", "self_harm", "other",
name="reason_category", create_type=False,
),
nullable=False,
),
sa.Column("details", sa.Text(), nullable=True),
sa.Column(
"status",
postgresql.ENUM(
"open", "in_review", "resolved", "dismissed",
name="report_status", create_type=False,
),
nullable=False,
server_default="open",
),
sa.Column("resolved_by", sa.Integer(), nullable=True),
sa.Column(
"resolution",
postgresql.ENUM(
"upheld", "modified", "dismissed",
name="resolution", create_type=False,
),
nullable=True,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["reporter_id"], ["users.id"], ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["resolved_by"], ["users.id"], ondelete="SET NULL",
),
)
op.create_index(op.f("ix_reports_id"), "reports", ["id"], unique=False)
op.create_index(
op.f("ix_reports_reporter_id"), "reports", ["reporter_id"], unique=False,
)
op.create_index(
op.f("ix_reports_status"), "reports", ["status"], unique=False,
)
op.create_index(
op.f("ix_reports_target"), "reports", ["target_type", "target_id"], unique=False,
)
# ── moderation_actions table ────────────────────────────────
op.create_table(
"moderation_actions",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("moderator_id", sa.Integer(), nullable=False),
sa.Column("target_user_id", sa.Integer(), nullable=False),
sa.Column(
"target_type",
postgresql.ENUM(
"user", "world", "asset", "chat_message",
name="target_type", create_type=False,
),
nullable=False,
),
sa.Column("target_id", sa.String(length=64), nullable=False),
sa.Column(
"action",
postgresql.ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore",
name="moderation_action_type", create_type=False,
),
nullable=False,
),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("report_id", sa.Integer(), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.ForeignKeyConstraint(
["moderator_id"], ["users.id"], ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["target_user_id"], ["users.id"], ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["report_id"], ["reports.id"], ondelete="SET NULL",
),
)
op.create_index(
op.f("ix_moderation_actions_id"),
"moderation_actions", ["id"], unique=False,
)
op.create_index(
op.f("ix_moderation_actions_moderator_id"),
"moderation_actions", ["moderator_id"], unique=False,
)
op.create_index(
op.f("ix_moderation_actions_target_user_id"),
"moderation_actions", ["target_user_id"], unique=False,
)
op.create_index(
op.f("ix_moderation_actions_target"),
"moderation_actions", ["target_type", "target_id"], unique=False,
)
# ── appeals table ───────────────────────────────────────────
op.create_table(
"appeals",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"moderation_action_id", sa.Integer(), nullable=False,
),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column(
"status",
postgresql.ENUM(
"pending", "approved", "rejected",
name="appeal_status", create_type=False,
),
nullable=False,
server_default="pending",
),
sa.Column("reviewed_by", sa.Integer(), nullable=True),
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.ForeignKeyConstraint(
["moderation_action_id"],
["moderation_actions.id"], ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["user_id"], ["users.id"], ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["reviewed_by"], ["users.id"], ondelete="SET NULL",
),
)
op.create_index(op.f("ix_appeals_id"), "appeals", ["id"], unique=False)
op.create_index(
op.f("ix_appeals_moderation_action_id"),
"appeals", ["moderation_action_id"], unique=False,
)
op.create_index(
op.f("ix_appeals_user_id"),
"appeals", ["user_id"], unique=False,
)
op.create_index(
op.f("ix_appeals_status"),
"appeals", ["status"], unique=False,
)
def downgrade() -> None:
"""Drop moderation tables and revert users table."""
op.drop_index(op.f("ix_appeals_status"), table_name="appeals")
op.drop_index(op.f("ix_appeals_user_id"), table_name="appeals")
op.drop_index(op.f("ix_appeals_moderation_action_id"), table_name="appeals")
op.drop_index(op.f("ix_appeals_id"), table_name="appeals")
op.drop_table("appeals")
op.drop_index(op.f("ix_moderation_actions_target"), table_name="moderation_actions")
op.drop_index(op.f("ix_moderation_actions_target_user_id"), table_name="moderation_actions")
op.drop_index(op.f("ix_moderation_actions_moderator_id"), table_name="moderation_actions")
op.drop_index(op.f("ix_moderation_actions_id"), table_name="moderation_actions")
op.drop_table("moderation_actions")
op.drop_index(op.f("ix_reports_target"), table_name="reports")
op.drop_index(op.f("ix_reports_status"), table_name="reports")
op.drop_index(op.f("ix_reports_reporter_id"), table_name="reports")
op.drop_index(op.f("ix_reports_id"), table_name="reports")
op.drop_table("reports")
op.drop_column("users", "suspended_until")
op.drop_column("users", "banned_until")
op.drop_column("users", "role")
# Drop ENUM types
postgresql.ENUM(name="appeal_status").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="moderation_action_type").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="resolution").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="report_status").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="reason_category").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="target_type").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="user_role").drop(op.get_bind(), checkfirst=True)
+26 -1
View File
@@ -2,5 +2,30 @@ from app.models.user import User
from app.models.session import Session from app.models.session import Session
from app.models.world import World from app.models.world import World
from app.models.asset import Asset from app.models.asset import Asset
from app.models.moderation import (
Report,
ModerationAction,
Appeal,
TargetType,
ReasonCategory,
ReportStatus,
Resolution,
ModerationActionType,
AppealStatus,
)
__all__ = ["User", "Session", "World", "Asset"] __all__ = [
"User",
"Session",
"World",
"Asset",
"Report",
"ModerationAction",
"Appeal",
"TargetType",
"ReasonCategory",
"ReportStatus",
"Resolution",
"ModerationActionType",
"AppealStatus",
]
-1
View File
@@ -13,7 +13,6 @@ class AssetType(str, enum.Enum):
VIDEO = "video" VIDEO = "video"
DOCUMENT = "document" DOCUMENT = "document"
MODEL_3D = "model_3d" MODEL_3D = "model_3d"
MODEL_GLTF = "model_gltf"
OTHER = "other" OTHER = "other"
+186
View File
@@ -0,0 +1,186 @@
"""Moderation data models for Trust & Safety."""
import enum
from datetime import datetime
from typing import Optional
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class TargetType(str, enum.Enum):
"""Type of entity being reported or moderated."""
USER = "user"
WORLD = "world"
ASSET = "asset"
CHAT_MESSAGE = "chat_message"
class ReasonCategory(str, enum.Enum):
"""Category of reason for a report."""
HARASSMENT = "harassment"
HATE_SPEECH = "hate_speech"
SEXUAL_CONTENT = "sexual_content"
VIOLENCE = "violence"
SPAM = "spam"
SELF_HARM = "self_harm"
OTHER = "other"
class ReportStatus(str, enum.Enum):
"""Status of a report."""
OPEN = "open"
IN_REVIEW = "in_review"
RESOLVED = "resolved"
DISMISSED = "dismissed"
class Resolution(str, enum.Enum):
"""Resolution outcome of a report."""
UPHELD = "upheld"
MODIFIED = "modified"
DISMISSED = "dismissed"
class ModerationActionType(str, enum.Enum):
"""Type of moderation action taken."""
WARN = "warn"
SUSPEND = "suspend"
BAN = "ban"
RESTRICT = "restrict"
REMOVE = "remove"
RESTORE = "restore"
class AppealStatus(str, enum.Enum):
"""Status of an appeal."""
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
class Report(Base):
"""A report filed by a user against a target entity."""
__tablename__ = "reports"
id = Column(Integer, primary_key=True, index=True)
reporter_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
target_type = Column(
ENUM(TargetType, name="target_type", create_type=False),
nullable=False,
)
target_id = Column(String(64), nullable=False)
reason_category = Column(
ENUM(ReasonCategory, name="reason_category", create_type=False),
nullable=False,
)
details = Column(Text, nullable=True)
status = Column(
ENUM(ReportStatus, name="report_status", create_type=False),
default=ReportStatus.OPEN,
nullable=False,
)
resolved_by = Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
resolution = Column(
ENUM(Resolution, name="resolution", create_type=False),
nullable=True,
)
created_at = Column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at = Column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
resolved_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
reporter = relationship("User", foreign_keys=[reporter_id])
resolver = relationship("User", foreign_keys=[resolved_by])
moderation_actions = relationship(
"ModerationAction", back_populates="report", cascade="all, delete-orphan"
)
appeals = relationship(
"Appeal", back_populates="report", cascade="all, delete-orphan"
)
class ModerationAction(Base):
"""A moderation action taken by a moderator against a user or entity."""
__tablename__ = "moderation_actions"
id = Column(Integer, primary_key=True, index=True)
moderator_id = Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=False
)
target_user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
target_type = Column(
ENUM(TargetType, name="target_type", create_type=False),
nullable=False,
)
target_id = Column(String(64), nullable=False)
action = Column(
ENUM(ModerationActionType, name="moderation_action_type", create_type=False),
nullable=False,
)
reason = Column(Text, nullable=True)
report_id = Column(
Integer, ForeignKey("reports.id", ondelete="SET NULL"), nullable=True
)
expires_at = Column(DateTime(timezone=True), nullable=True)
revoked_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
# Relationships
moderator = relationship("User", foreign_keys=[moderator_id])
target_user = relationship("User", foreign_keys=[target_user_id])
report = relationship("Report", back_populates="moderation_actions")
appeals = relationship(
"Appeal", back_populates="moderation_action", cascade="all, delete-orphan"
)
class Appeal(Base):
"""An appeal filed by a user against a moderation action."""
__tablename__ = "appeals"
id = Column(Integer, primary_key=True, index=True)
moderation_action_id = Column(
Integer, ForeignKey("moderation_actions.id", ondelete="CASCADE"),
nullable=False,
)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
reason = Column(Text, nullable=False)
status = Column(
ENUM(AppealStatus, name="appeal_status", create_type=False),
default=AppealStatus.PENDING,
nullable=False,
)
reviewed_by = Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
reviewed_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
# Relationships
moderation_action = relationship("ModerationAction", back_populates="appeals")
user = relationship("User", foreign_keys=[user_id])
reviewer = relationship("User", foreign_keys=[reviewed_by])
+17 -2
View File
@@ -1,13 +1,21 @@
from datetime import date from datetime import date
from typing import Optional from typing import Optional
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date, func
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func import enum
from app.database import Base from app.database import Base
class UserRole(str, enum.Enum):
"""User role for moderation and access control."""
USER = "user"
MODERATOR = "moderator"
ADMIN = "admin"
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
@@ -17,6 +25,13 @@ class User(Base):
hashed_password = Column(String(255), nullable=False) hashed_password = Column(String(255), nullable=False)
date_of_birth = Column(Date, nullable=True) date_of_birth = Column(Date, nullable=True)
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
role = Column(
ENUM(UserRole, name="user_role", create_type=False),
default=UserRole.USER,
nullable=False,
)
banned_until = Column(DateTime(timezone=True), nullable=True)
suspended_until = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
-258
View File
@@ -1,6 +1,5 @@
"""Asset management router.""" """Asset management router."""
import json
import os import os
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -65,177 +64,6 @@ def _sanitize_filename(filename: str) -> str:
return safe or "file" return safe or "file"
def _validate_gltf_json(content: bytes) -> dict:
"""Validate that content is a valid .gltf file (JSON-based glTF).
Returns the parsed JSON dict on success.
Raises ValueError if the file is not valid glTF.
"""
try:
data = json.loads(content)
except (json.JSONDecodeError, UnicodeDecodeError) as e:
raise ValueError(f"Invalid JSON in .gltf file: {e}")
if not isinstance(data, dict):
raise ValueError("glTF file root must be a JSON object")
if "asset" not in data:
raise ValueError("Missing required 'asset' property in glTF file")
if "scenes" not in data and "meshes" not in data and "nodes" not in data:
# glTF 2.0 spec: at least one of scenes, meshes, nodes, accessors,
# bufferViews, buffers, materials, textures, samplers, skin,
# animations, or extensions must be present.
# For simplicity, we require at least one of scenes/meshes/nodes.
raise ValueError(
"glTF file must contain at least one of: scenes, meshes, or nodes"
)
return data
def _validate_glb(content: bytes) -> dict:
"""Validate that content is a valid .glb file (binary glTF).
Returns the parsed JSON chunk data on success.
Raises ValueError if the file is not valid glTF.
"""
if len(content) < 12:
raise ValueError("glb file too short")
# Check magic bytes: must start with b'glTF'
magic = content[:4]
if magic != b"glTF":
raise ValueError(
f"Invalid glb magic bytes: expected b'glTF', got {magic!r}"
)
# Version check (we support version 2)
version = int.from_bytes(content[4:8], byteorder="little")
if version != 2:
raise ValueError(f"Unsupported glTF version: {version} (expected 2)")
# Total length check
total_length = int.from_bytes(content[8:12], byteorder="little")
if total_length != len(content):
raise ValueError(
f"glb total length mismatch: header says {total_length}, "
f"actual is {len(content)}"
)
# Parse JSON chunk
# Chunk format: length (4 bytes) + type (4 bytes) + data
offset = 12
json_chunk_data = None
while offset < len(content):
if offset + 8 > len(content):
raise ValueError("Incomplete chunk header in glb file")
chunk_length = int.from_bytes(content[offset:offset + 4], byteorder="little")
chunk_type = content[offset + 4:offset + 8]
if chunk_length + 8 > len(content) - offset:
raise ValueError("Chunk extends beyond file end")
if chunk_type == b"JSON":
json_chunk_data = content[offset + 8:offset + 8 + chunk_length]
# JSON chunk may have padding
break
elif chunk_type == b"BIN":
# Skip binary chunk
offset += 8 + chunk_length
continue
else:
# Unknown chunk type - skip it
offset += 8 + chunk_length
continue
if json_chunk_data is None:
raise ValueError("No JSON chunk found in glb file")
try:
data = json.loads(json_chunk_data)
except (json.JSONDecodeError, UnicodeDecodeError) as e:
raise ValueError(f"Invalid JSON in glb chunk: {e}")
if not isinstance(data, dict):
raise ValueError("glTF file root must be a JSON object")
if "asset" not in data:
raise ValueError("Missing required 'asset' property in glTF file")
return data
def _extract_metadata(data: dict) -> dict:
"""Extract metadata from parsed glTF data.
Returns a dict with:
- bounding_box: {"min": [...], "max": [...]} or null
- vertex_count: int
- material_count: int
- has_animation: bool
"""
metadata = {
"bounding_box": None,
"vertex_count": 0,
"material_count": 0,
"has_animation": False,
}
# Count materials
if "materials" in data and isinstance(data["materials"], list):
metadata["material_count"] = len(data["materials"])
# Check for animations
if "animations" in data and isinstance(data["animations"], list):
metadata["has_animation"] = len(data["animations"]) > 0
# Count vertices from meshes
vertex_count = 0
bounds_min = None
bounds_max = None
if "meshes" in data and isinstance(data["meshes"], list):
for mesh in data["meshes"]:
if "primitives" in mesh and isinstance(mesh["primitives"], list):
for primitive in mesh["primitives"]:
# Accessor index for positions
pos_accessor_idx = primitive.get("attributes", {}).get("POSITION")
if pos_accessor_idx is not None:
accessors = data.get("accessors", [])
if isinstance(accessors, list) and 0 <= pos_accessor_idx < len(accessors):
accessor = accessors[pos_accessor_idx]
count = accessor.get("count", 0)
vertex_count += count
# Extract bounding box from min/max
if "min" in accessor and "max" in accessor:
acc_min = accessor["min"]
acc_max = accessor["max"]
if bounds_min is None:
bounds_min = acc_min
bounds_max = acc_max
else:
bounds_min = [
min(a, b) for a, b in zip(bounds_min, acc_min)
]
bounds_max = [
max(a, b) for a, b in zip(bounds_max, acc_max)
]
metadata["vertex_count"] = vertex_count
if bounds_min is not None and bounds_max is not None:
metadata["bounding_box"] = {
"min": bounds_min,
"max": bounds_max,
}
return metadata
# --- Endpoints --- # --- Endpoints ---
@router.post("/", response_model=AssetResponse, status_code=201) @router.post("/", response_model=AssetResponse, status_code=201)
@@ -379,89 +207,3 @@ async def upload_asset_file(
await db.commit() await db.commit()
await db.refresh(asset) await db.refresh(asset)
return asset return asset
@router.post("/import-gltf", response_model=AssetResponse, status_code=201)
async def import_gltf(
current_user: CurrentUser,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
):
"""Import a glTF or glb file as an asset.
Accepts a .gltf or .glb file, validates it, extracts metadata
(bounding box, vertex count, material count, animation flag),
stores the file, and returns an AssetResponse with the new asset's ID.
"""
filename = file.filename or ""
lower_filename = filename.lower()
# Validate file extension
if not (lower_filename.endswith(".gltf") or lower_filename.endswith(".glb")):
raise HTTPException(
status_code=400,
detail="File must be a .gltf or .glb file",
)
# Validate file size against configured maximum.
max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
content = await file.read()
file_size = len(content)
if file_size > max_bytes:
raise HTTPException(
status_code=413,
detail=f"File size {file_size} exceeds maximum allowed size {max_bytes} bytes",
)
# Validate file content based on format
try:
if lower_filename.endswith(".gltf"):
gltf_data = _validate_gltf_json(content)
else:
gltf_data = _validate_glb(content)
except ValueError as e:
raise HTTPException(
status_code=400,
detail=f"Invalid glTF file: {e}",
)
# Extract metadata
metadata = _extract_metadata(gltf_data)
# Create the asset record
asset = Asset(
name=Path(filename).stem,
description=None,
asset_type=AssetType.MODEL_GLTF,
file_path="", # Will be set after file storage
owner_id=current_user.id,
world_id=None,
is_public=False,
)
db.add(asset)
await db.commit()
await db.refresh(asset)
# Sanitize filename for storage.
# The original extension was validated above, so we keep exactly one
# extension after the sanitized stem.
original_stem = Path(filename).stem
safe_stem = _sanitize_filename(original_stem)
extension = ".gltf" if lower_filename.endswith(".gltf") else ".glb"
stored_name = f"{asset.id}_{safe_stem}{extension}"
# Write the file to the upload directory
upload_dir = Path(settings.UPLOAD_DIR)
upload_dir.mkdir(parents=True, exist_ok=True)
file_path = upload_dir / stored_name
file_path.write_bytes(content)
# Update asset with file info and metadata
asset.file_path = str(file_path)
asset.file_size = file_size
asset.mime_type = "model/gltf+json" if lower_filename.endswith(".gltf") else "model/gltf+binary"
asset.metadata_json = json.dumps(metadata)
await db.commit()
await db.refresh(asset)
return asset
-493
View File
@@ -1,493 +0,0 @@
"""Tests for the glTF import pipeline."""
import json
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.config import settings
from app.database import get_db, SessionLocal
from app.models.asset import Asset, AssetType
from app.models.user import User
from app.main import app
# --- Fixtures ---
@pytest.fixture
def db_session():
"""Provide a test database session with rollback."""
async_session = SessionLocal()
async def override_get_db():
return async_session
app.dependency_overrides[get_db] = override_get_db
yield async_session
async def cleanup():
await async_session.rollback()
await async_session.close()
import asyncio
asyncio.get_event_loop().run_until_complete(cleanup())
app.dependency_overrides.clear()
@pytest.fixture
def test_user(db_session):
"""Create a test user."""
user = User(
username="test_gltf_user",
email="gltf_test@example.com",
hashed_password="$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36PQm3iV8jEGPym2fA1Wile", # pass
is_active=True,
)
db_session.add(user)
import asyncio
asyncio.get_event_loop().run_until_complete(db_session.commit())
asyncio.get_event_loop().run_until_complete(db_session.refresh(user))
return user
@pytest.fixture
def client(db_session, test_user):
"""Create a test client with auth."""
from jose import jwt
from datetime import datetime, timedelta, timezone
token = jwt.encode(
{
"sub": str(test_user.id),
"exp": datetime.now(timezone.utc)
+ timedelta(minutes=30),
},
settings.SECRET_KEY,
algorithm=settings.ALGORITHM,
)
from fastapi.testclient import TestClient
client = TestClient(app)
client.headers["Authorization"] = f"Bearer {token}"
return client
# --- Helpers for building test glTF files ---
def make_gltf_json(
scenes=True,
meshes=True,
nodes=False,
materials=0,
animations=0,
vertex_count=0,
has_bounds=True,
):
"""Build a minimal valid .gltf JSON structure."""
data = {
"asset": {"version": "2.0", "generator": "test"},
}
if scenes:
data["scenes"] = [{"nodes": [0]}]
if nodes:
data["nodes"] = [{"name": "root"}]
if meshes:
accessors = []
if vertex_count > 0 and has_bounds:
accessors.append(
{
"count": vertex_count,
"componentType": 5126,
"type": "VEC3",
"min": [0.0, 0.0, 0.0],
"max": [1.0, 1.0, 1.0],
}
)
elif vertex_count > 0:
accessors.append(
{
"count": vertex_count,
"componentType": 5126,
"type": "VEC3",
}
)
meshes_list = []
for _ in range(1):
prim = {"attributes": {"POSITION": 0}} if accessors else {}
meshes_list.append({"primitives": [prim]})
data["meshes"] = meshes_list
if accessors:
data["accessors"] = accessors
if materials > 0:
data["materials"] = [{"name": f"mat_{i}"} for i in range(materials)]
if animations > 0:
data["animations"] = [{"name": f"anim_{i}"} for i in range(animations)]
return data
def make_glb(data_dict):
"""Build a minimal valid .glb binary file from a dict."""
json_bytes = json.dumps(data_dict).encode("utf-8")
# Pad JSON to 4-byte boundary
json_padding = (4 - len(json_bytes) % 4) % 4
json_bytes += b" " * json_padding
json_chunk_length = len(json_bytes)
json_chunk_type = b"JSON"
# No binary chunks for simplicity
total_length = 12 + 8 + json_chunk_length + 8 # header + json chunk header + data + BIN padding
header = b"glTF"
header += int.to_bytes(2, 4, "little") # version
header += int.to_bytes(total_length, 4, "little") # total length
chunk_header = int.to_bytes(json_chunk_length, 4, "little")
chunk_header += json_chunk_type
# Add a BIN chunk with zero length to pad
bin_chunk_length = 0
bin_chunk_type = b"BIN"
bin_padding = (4 - bin_chunk_length % 4) % 4
return (
header
+ chunk_header
+ json_bytes
+ bin_chunk_type
+ int.to_bytes(bin_chunk_length, 4, "little")
+ b" " * bin_padding
)
# --- Tests: Extension validation ---
class TestImportGltfExtensionValidation:
"""Test that only .gltf and .glb files are accepted."""
def test_rejects_non_gltf_extension(self, client):
"""Files without .gltf or .glb extension return 400."""
response = client.post(
"/api/assets/import-gltf",
files={"file": ("test.png", b"fake png data", "image/png")},
)
assert response.status_code == 400
assert ".gltf" in response.json()["detail"].lower() or ".glb" in response.json()["detail"].lower()
def test_rejects_txt_extension(self, client):
"""A .txt file is rejected."""
response = client.post(
"/api/assets/import-gltf",
files={"file": ("model.txt", b"not a gltf", "text/plain")},
)
assert response.status_code == 400
# --- Tests: .gltf validation ---
class TestImportGltfJsonValidation:
"""Test validation of .gltf (JSON) files."""
def test_valid_minimal_gltf(self, client):
"""A minimal valid .gltf is accepted."""
data = make_gltf_json(vertex_count=24, materials=2, animations=1)
json_bytes = json.dumps(data).encode("utf-8")
response = client.post(
"/api/assets/import-gltf",
files={"file": ("model.gltf", json_bytes, "model/gltf+json")},
)
assert response.status_code == 201
body = response.json()
assert body["asset_type"] == "model_gltf"
assert body["file_size"] == len(json_bytes)
assert body["mime_type"] == "model/gltf+json"
metadata = json.loads(body["metadata_json"])
assert metadata["vertex_count"] == 24
assert metadata["material_count"] == 2
assert metadata["has_animation"] is True
def test_rejects_invalid_json(self, client):
"""Malformed JSON returns 400."""
response = client.post(
"/api/assets/import-gltf",
files={"file": ("bad.gltf", b"{not valid json", "model/gltf+json")},
)
assert response.status_code == 400
assert "Invalid JSON" in response.json()["detail"]
def test_rejects_gltf_missing_asset(self, client):
"""glTF without 'asset' property returns 400."""
data = {"scenes": [{}]}
json_bytes = json.dumps(data).encode("utf-8")
response = client.post(
"/api/assets/import-gltf",
files={"file": ("no_asset.gltf", json_bytes, "model/gltf+json")},
)
assert response.status_code == 400
assert "Missing required 'asset'" in response.json()["detail"]
def test_rejects_gltf_no_meshes_scenes_nodes(self, client):
"""glTF with neither scenes, meshes, nor nodes returns 400."""
data = {"asset": {"version": "2.0"}, "materials": []}
json_bytes = json.dumps(data).encode("utf-8")
response = client.post(
"/api/assets/import-gltf",
files={"file": ("empty.gltf", json_bytes, "model/gltf+json")},
)
assert response.status_code == 400
assert "scenes, meshes, or nodes" in response.json()["detail"]
# --- Tests: .glb validation ---
class TestImportGlbValidation:
"""Test validation of .glb (binary) files."""
def test_valid_minimal_glb(self, client):
"""A minimal valid .glb is accepted."""
data = make_gltf_json(vertex_count=12, materials=1)
glb_bytes = make_glb(data)
response = client.post(
"/api/assets/import-gltf",
files={"file": ("model.glb", glb_bytes, "model/gltf+binary")},
)
assert response.status_code == 201
body = response.json()
assert body["asset_type"] == "model_gltf"
assert body["file_size"] == len(glb_bytes)
assert body["mime_type"] == "model/gltf+binary"
metadata = json.loads(body["metadata_json"])
assert metadata["vertex_count"] == 12
assert metadata["material_count"] == 1
def test_rejects_invalid_magic_bytes(self, client):
"""File not starting with 'glTF' magic returns 400."""
bad_glb = b"xxxx" + int.to_bytes(2, 4, "little") + int.to_bytes(16, 4, "little")
bad_glb += int.to_bytes(4, 4, "little") + b"JSON" + b"{}" + b"BIN" + int.to_bytes(0, 4, "little")
response = client.post(
"/api/assets/import-gltf",
files={"file": ("bad.glb", bad_glb, "model/gltf+binary")},
)
assert response.status_code == 400
assert "magic" in response.json()["detail"].lower()
def test_rejects_wrong_version(self, client):
"""glTF version 1 returns 400."""
data = {"asset": {"version": "1.0"}}
json_bytes = json.dumps(data).encode("utf-8")
json_padding = (4 - len(json_bytes) % 4) % 4
json_bytes += b" " * json_padding
json_len = len(json_bytes)
header = b"glTF" + int.to_bytes(1, 4, "little") + int.to_bytes(12 + 8 + json_len + 8, 4, "little")
chunk = int.to_bytes(json_len, 4, "little") + b"JSON"
bin_pad = b"BIN" + int.to_bytes(0, 4, "little")
glb_bytes = header + chunk + json_bytes + bin_pad
response = client.post(
"/api/assets/import-gltf",
files={"file": ("v1.glb", glb_bytes, "model/gltf+binary")},
)
assert response.status_code == 400
assert "Unsupported glTF version" in response.json()["detail"]
def test_rejects_too_short(self, client):
"""A file shorter than 12 bytes returns 400."""
response = client.post(
"/api/assets/import-gltf",
files={"file": ("tiny.glb", b"glTF123", "model/gltf+binary")},
)
assert response.status_code == 400
assert "too short" in response.json()["detail"].lower()
# --- Tests: File size validation ---
class TestImportGltfFileSize:
"""Test file size limits."""
def test_rejects_oversized_file(self, client):
"""Files exceeding MAX_UPLOAD_SIZE_MB return 413."""
# Create a file larger than the default 100 MB limit
large_content = b"X" * (settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024 + 1)
response = client.post(
"/api/assets/import-gltf",
files={"file": ("big.gltf", large_content, "model/gltf+json")},
)
assert response.status_code == 413
assert "exceeds maximum" in response.json()["detail"].lower()
# --- Tests: Metadata extraction ---
class TestMetadataExtraction:
"""Test that metadata is correctly extracted from glTF data."""
def test_vertex_count_from_meshes(self, client):
"""Vertex count is extracted from mesh primitives."""
data = make_gltf_json(vertex_count=100, materials=0, animations=0)
json_bytes = json.dumps(data).encode("utf-8")
response = client.post(
"/api/assets/import-gltf",
files={"file": ("model.gltf", json_bytes, "model/gltf+json")},
)
assert response.status_code == 201
metadata = json.loads(response.json()["metadata_json"])
assert metadata["vertex_count"] == 100
def test_material_count(self, client):
"""Material count is extracted from materials array."""
data = make_gltf_json(materials=5, vertex_count=0)
json_bytes = json.dumps(data).encode("utf-8")
response = client.post(
"/api/assets/import-gltf",
files={"file": ("model.gltf", json_bytes, "model/gltf+json")},
)
assert response.status_code == 201
metadata = json.loads(response.json()["metadata_json"])
assert metadata["material_count"] == 5
def test_has_animation_flag(self, client):
"""has_animation is True when animations exist."""
data = make_gltf_json(animations=3, vertex_count=0)
json_bytes = json.dumps(data).encode("utf-8")
response = client.post(
"/api/assets/import-gltf",
files={"file": ("model.gltf", json_bytes, "model/gltf+json")},
)
assert response.status_code == 201
metadata = json.loads(response.json()["metadata_json"])
assert metadata["has_animation"] is True
def test_no_animation_flag(self, client):
"""has_animation is False when no animations exist."""
data = make_gltf_json(animations=0, vertex_count=0)
json_bytes = json.dumps(data).encode("utf-8")
response = client.post(
"/api/assets/import-gltf",
files={"file": ("model.gltf", json_bytes, "model/gltf+json")},
)
assert response.status_code == 201
metadata = json.loads(response.json()["metadata_json"])
assert metadata["has_animation"] is False
def test_bounding_box_extraction(self, client):
"""Bounding box is extracted from accessor min/max."""
data = make_gltf_json(vertex_count=8, has_bounds=True)
json_bytes = json.dumps(data).encode("utf-8")
response = client.post(
"/api/assets/import-gltf",
files={"file": ("model.gltf", json_bytes, "model/gltf+json")},
)
assert response.status_code == 201
metadata = json.loads(response.json()["metadata_json"])
assert metadata["bounding_box"] is not None
assert "min" in metadata["bounding_box"]
assert "max" in metadata["bounding_box"]
def test_no_bounding_box_without_accessor_bounds(self, client):
"""Bounding box is None when accessor has no min/max."""
data = make_gltf_json(vertex_count=8, has_bounds=False)
json_bytes = json.dumps(data).encode("utf-8")
response = client.post(
"/api/assets/import-gltf",
files={"file": ("model.gltf", json_bytes, "model/gltf+json")},
)
assert response.status_code == 201
metadata = json.loads(response.json()["metadata_json"])
assert metadata["bounding_box"] is None
# --- Tests: Database persistence ---
class TestDatabasePersistence:
"""Test that assets are correctly stored in the database."""
def test_asset_created_in_db(self, client, db_session, test_user):
"""An asset record is created in the database."""
data = make_gltf_json(vertex_count=10)
json_bytes = json.dumps(data).encode("utf-8")
response = client.post(
"/api/assets/import-gltf",
files={"file": ("model.gltf", json_bytes, "model/gltf+json")},
)
assert response.status_code == 201
# Verify in database
result = db_session.execute(select(Asset).where(Asset.id == response.json()["id"]))
asset = result.scalar_one_or_none()
assert asset is not None
assert asset.asset_type == AssetType.MODEL_GLTF
assert asset.owner_id == test_user.id
assert asset.file_size == len(json_bytes)
assert asset.metadata_json is not None
def test_file_stored_on_disk(self, client, db_session, test_user):
"""The file is stored in the upload directory."""
data = make_gltf_json(vertex_count=10)
json_bytes = json.dumps(data).encode("utf-8")
response = client.post(
"/api/assets/import-gltf",
files={"file": ("model.gltf", json_bytes, "model/gltf+json")},
)
assert response.status_code == 201
asset_id = response.json()["id"]
upload_dir = Path(settings.UPLOAD_DIR)
# The stored file should exist
found = False
for f in upload_dir.iterdir():
if f.name.startswith(f"{asset_id}_"):
found = True
assert f.read_bytes() == json_bytes
break
assert found, f"File not found in {upload_dir}"
# --- Tests: Filename sanitization ---
class TestFilenameSanitization:
"""Test that filenames are properly sanitized."""
def test_sanitizes_special_chars(self, client):
"""Special characters in filename are stripped."""
data = make_gltf_json(vertex_count=10)
json_bytes = json.dumps(data).encode("utf-8")
response = client.post(
"/api/assets/import-gltf",
files={"file": ("my model (1).gltf", json_bytes, "model/gltf+json")},
)
assert response.status_code == 201
# The name should be the stem without special chars
assert response.json()["name"] == "my model 1"
def test_preserves_extension(self, client):
"""The .gltf extension is preserved in the stored file."""
data = make_gltf_json(vertex_count=10)
json_bytes = json.dumps(data).encode("utf-8")
response = client.post(
"/api/assets/import-gltf",
files={"file": ("model.gltf", json_bytes, "model/gltf+json")},
)
assert response.status_code == 201
upload_dir = Path(settings.UPLOAD_DIR)
for f in upload_dir.iterdir():
if f.name.endswith(".gltf"):
assert f.read_bytes() == json_bytes
break