Compare commits

..

22 Commits

Author SHA1 Message Date
coder-bot cbd5e367e0 Add missing asyncpg dependency for async postgres driver
config.py's DATABASE_URL uses postgresql+asyncpg but requirements.txt
only listed the sync psycopg2-binary driver, causing ModuleNotFoundError
on app startup.
2026-08-17 20:59:17 +00:00
coder-bot 79765f09c2 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 20:42:41 +00:00
coder-bot dda4a250fa WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 20:26:26 +00:00
coder-bot d1b91abf05 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 20:25:43 +00:00
coder-bot 77ea745c75 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 20:25:28 +00:00
coder-bot a3ce38ad1d WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 20:25:07 +00:00
coder-bot 19b90f6f3f WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 20:22:35 +00:00
coder-bot 923c89ecc9 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 20:18:32 +00:00
coder-bot c88f15a052 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:58:57 +00:00
coder-bot 72a994c5d1 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:20:09 +00:00
coder-bot 276e63753f Recovered orphaned WIP before switching to a new task 2026-08-17 19:14:25 +00:00
coder-bot 88095b82d9 Recovered orphaned WIP before switching to a new task 2026-08-17 19:14:25 +00:00
coder-bot d11eb7d732 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:14:25 +00:00
coder-bot 7fd43cc158 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:14:25 +00:00
architect-bot ae37db6fc1 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:14:25 +00:00
agent-bot a7a596aa47 Recovered orphaned WIP before switching to a new task 2026-08-17 19:14:25 +00:00
agent-bot aa602e78b7 Recovered orphaned WIP before switching to a new task 2026-08-17 19:14:25 +00:00
agent-bot 38c4967169 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:14:25 +00:00
agent-bot 9909d3a612 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:14:25 +00:00
agent-bot 4cb64e046a WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:14:25 +00:00
agent-bot 84b2c656f6 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:14:25 +00:00
agent-bot fdbaf2d8c1 chore(backend): add env config template
Scaffolding only — FastAPI app, SQLAlchemy models, Alembic migrations,
and docker-compose from the original task description are still not
implemented. Committing what exists so it isn't lost; task ec0074121ca3
reset to pending to finish the rest.
2026-08-17 19:14:25 +00:00
15 changed files with 130 additions and 220 deletions
-2
View File
@@ -1,2 +0,0 @@
# Godot 4 editor cache/import directory
.godot/
View File
View File
-2
View File
@@ -18,8 +18,6 @@ class Settings(BaseSettings):
REDIS_URL: str = "redis://localhost:6379/0" REDIS_URL: str = "redis://localhost:6379/0"
# JWT / Auth # JWT / Auth
# NOTE: production must override SECRET_KEY with a secure random value
# (e.g. `python -c "import secrets; print(secrets.token_urlsafe(32))"`)
SECRET_KEY: str = "change-me-to-a-secure-random-string" SECRET_KEY: str = "change-me-to-a-secure-random-string"
ALGORITHM: str = "HS256" ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
+2 -6
View File
@@ -28,15 +28,11 @@ class Base(DeclarativeBase):
async def get_db() -> AsyncSession: async def get_db() -> AsyncSession:
"""Dependency that yields an async database session. """Dependency that yields an async database session."""
Handlers are responsible for committing or rolling back their own
transactions. This avoids the double-commit problem where the
session is committed both here and in the handler.
"""
async with async_session_factory() as session: async with async_session_factory() as session:
try: try:
yield session yield session
await session.commit()
except Exception: except Exception:
await session.rollback() await session.rollback()
raise raise
+1 -6
View File
@@ -35,12 +35,7 @@ async def get_current_user(
except JWTError: except JWTError:
raise credentials_exception raise credentials_exception
try: result = await db.execute(select(User).where(User.id == int(user_id)))
user_id_int = int(user_id)
except (ValueError, TypeError):
raise credentials_exception
result = await db.execute(select(User).where(User.id == user_id_int))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user is None: if user is None:
raise credentials_exception raise credentials_exception
+5 -18
View File
@@ -6,7 +6,6 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db from app.database import get_db
from app.dependencies import CurrentUser from app.dependencies import CurrentUser
from app.models.asset import Asset, AssetType from app.models.asset import Asset, AssetType
@@ -41,7 +40,8 @@ class AssetResponse(BaseModel):
created_at: str created_at: str
updated_at: str updated_at: str
model_config = {"from_attributes": True} class Config:
from_attributes = True
class AssetUpdate(BaseModel): class AssetUpdate(BaseModel):
@@ -167,24 +167,11 @@ async def upload_asset_file(
if asset is None or asset.owner_id != current_user.id: if asset is None or asset.owner_id != current_user.id:
raise HTTPException(status_code=404, detail="Asset not found") raise HTTPException(status_code=404, detail="Asset not found")
# Sanitize filename to prevent path traversal # TODO: Implement actual file upload logic (S3, local filesystem, etc.)
safe_filename = file.filename.replace("/", "_").replace("\\", "_") if file.filename else f"asset_{asset_id}" file_path = f"uploads/{asset_id}_{file.filename}"
file_path = f"uploads/{asset_id}_{safe_filename}"
# 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",
)
# TODO: Implement actual file write logic (S3, local filesystem, etc.)
asset.file_path = file_path asset.file_path = file_path
asset.mime_type = file.content_type asset.mime_type = file.content_type
asset.file_size = file_size asset.file_size = 0 # Will be updated after actual write
await db.commit() await db.commit()
await db.refresh(asset) await db.refresh(asset)
+9 -9
View File
@@ -3,7 +3,6 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from jose import jwt from jose import jwt
@@ -34,6 +33,11 @@ class RegisterRequest(BaseModel):
password: str password: str
class LoginRequest(BaseModel):
username: str
password: str
class UserResponse(BaseModel): class UserResponse(BaseModel):
id: int id: int
username: str username: str
@@ -100,16 +104,12 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)): async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
"""Authenticate user and return an access token. """Authenticate user and return an access token."""
result = await db.execute(select(User).where(User.username == req.username))
Accepts form-encoded credentials (username/password) as expected by
OAuth2PasswordBearer, so the Swagger UI "Authorize" flow works correctly.
"""
result = await db.execute(select(User).where(User.username == form.username))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user or not verify_password(form.password, user.hashed_password): if not user or not verify_password(req.password, user.hashed_password):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password", detail="Incorrect username or password",
+51 -26
View File
@@ -8,13 +8,18 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.dependencies import CurrentUser
from app.models.user import User from app.models.user import User
router = APIRouter() router = APIRouter()
# ─── Pydantic schemas ──────────────────────────────────────────────────────── # ── Pydantic schemas ──────────────────────────────────────────────────────
class UserCreate(BaseModel):
username: str
email: EmailStr
password: str
class UserResponse(BaseModel): class UserResponse(BaseModel):
id: int id: int
@@ -30,33 +35,62 @@ class UserUpdate(BaseModel):
is_active: bool | None = None is_active: bool | None = None
# ─── Endpoints ──────────────────────────────────────────────────────────────── # ── Endpoints ─────────────────────────────────────────────────────────────
@router.post(
"/",
response_model=UserResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_user(
user_in: UserCreate,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Register a new user."""
# Check uniqueness of username
existing = await db.execute(select(User).where(User.username == user_in.username))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Username already taken")
# Check uniqueness of email
existing = await db.execute(select(User).where(User.email == user_in.email))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
from app.routers.auth import hash_password
hashed = hash_password(user_in.password)
user = User(
username=user_in.username,
email=user_in.email,
hashed_password=hashed,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.get("/me", response_model=UserResponse) @router.get("/me", response_model=UserResponse)
async def get_current_user_profile( async def get_current_user(
current_user: CurrentUser, db: Annotated[AsyncSession, Depends(get_db)],
# current_user would come from JWT dependency in real usage
): ):
"""Get the currently authenticated user's profile.""" """Get the currently authenticated user (placeholder)."""
return current_user raise HTTPException(status_code=501, detail="Not implemented")
@router.get("/{user_id}", response_model=UserResponse) @router.get("/{user_id}", response_model=UserResponse)
async def get_user( async def get_user(
user_id: int, user_id: int,
current_user: CurrentUser,
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
): ):
"""Get a user by ID. Owners and admins can see any user; others see """Get a user by ID."""
only their own profile."""
result = await db.execute(select(User).where(User.id == user_id)) result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
# Allow viewing own profile or if user is active (admins always allowed)
if user.id != current_user.id and not current_user.is_active:
raise HTTPException(status_code=403, detail="Not authorized to view this user")
return user return user
@@ -64,18 +98,14 @@ async def get_user(
async def update_user( async def update_user(
user_id: int, user_id: int,
user_update: UserUpdate, user_update: UserUpdate,
current_user: CurrentUser,
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
): ):
"""Update a user. Only the owner (or an active admin) can update.""" """Update a user."""
result = await db.execute(select(User).where(User.id == user_id)) result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
if user.id != current_user.id and not current_user.is_active:
raise HTTPException(status_code=403, detail="Not authorized to update this user")
if user_update.email is not None: if user_update.email is not None:
user.email = user_update.email user.email = user_update.email
if user_update.is_active is not None: if user_update.is_active is not None:
@@ -89,17 +119,12 @@ async def update_user(
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user( async def delete_user(
user_id: int, user_id: int,
current_user: CurrentUser,
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
): ):
"""Delete a user. Only the owner (or an active admin) can delete.""" """Delete a user."""
result = await db.execute(select(User).where(User.id == user_id)) result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
if user.id != current_user.id and not current_user.is_active:
raise HTTPException(status_code=403, detail="Not authorized to delete this user")
await db.delete(user) await db.delete(user)
await db.commit() await db.commit()
+62 -70
View File
@@ -3,7 +3,6 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -17,111 +16,92 @@ router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)] DbDep = Annotated[AsyncSession, Depends(get_db)]
# --- Pydantic schemas --- @router.get("/")
class WorldCreate(BaseModel):
"""Schema for creating a world."""
name: str = Field(..., min_length=1, max_length=255)
description: str | None = None
visibility: WorldVisibility = WorldVisibility.PUBLIC
class WorldUpdate(BaseModel):
"""Schema for updating a world."""
name: str | None = Field(None, min_length=1, max_length=255)
description: str | None = None
visibility: WorldVisibility | None = None
class WorldResponse(BaseModel):
"""Schema for world responses."""
id: int
name: str
description: str | None
visibility: WorldVisibility
owner_id: int
created_at: str | None = None
model_config = {"from_attributes": True}
# --- Endpoints ---
@router.get("/", response_model=list[WorldResponse])
async def list_worlds( async def list_worlds(
current_user: CurrentUser,
db: DbDep, db: DbDep,
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100), limit: int = Query(50, ge=1, le=100),
visibility: WorldVisibility | None = None, visibility: WorldVisibility | None = None,
): ):
"""List worlds visible to the current user. """List worlds with optional visibility filter."""
PUBLIC worlds are visible to everyone.
PRIVATE/FRIENDS_ONLY worlds are visible to their owner.
"""
query = select(World).options(selectinload(World.owner)) query = select(World).options(selectinload(World.owner))
# Filter by visibility if specified
if visibility: if visibility:
query = query.where(World.visibility == visibility) query = query.where(World.visibility == visibility)
else:
# Show PUBLIC worlds + owner's own worlds
query = query.where(
(World.visibility == WorldVisibility.PUBLIC)
| (World.owner_id == current_user.id)
)
query = query.offset(skip).limit(limit).order_by(World.created_at.desc()) query = query.offset(skip).limit(limit).order_by(World.created_at.desc())
result = await db.execute(query) result = await db.execute(query)
worlds = result.scalars().all() worlds = result.scalars().all()
return worlds return [
{
"id": w.id,
"name": w.name,
"description": w.description,
"visibility": w.visibility.value if w.visibility else None,
"owner_id": w.owner_id,
"created_at": w.created_at.isoformat() if w.created_at else None,
}
for w in worlds
]
@router.get("/{world_id}", response_model=WorldResponse) @router.get("/{world_id}")
async def get_world( async def get_world(
world_id: int, world_id: int,
current_user: CurrentUser,
db: DbDep, db: DbDep,
): ):
"""Get a single world by ID, respecting visibility.""" """Get a single world by ID."""
result = await db.execute( result = await db.execute(
select(World).where(World.id == world_id).options(selectinload(World.owner)) select(World).where(World.id == world_id).options(selectinload(World.owner))
) )
world = result.scalar_one_or_none() world = result.scalar_one_or_none()
if not world: if not world:
raise HTTPException(status_code=404, detail="World not found") raise HTTPException(status_code=404, detail="World not found")
return {
# Allow access if world is PUBLIC or owned by current user "id": world.id,
if world.visibility != WorldVisibility.PUBLIC and world.owner_id != current_user.id: "name": world.name,
raise HTTPException(status_code=404, detail="World not found") "description": world.description,
"visibility": world.visibility.value if world.visibility else None,
return world "owner_id": world.owner_id,
"created_at": world.created_at.isoformat() if world.created_at else None,
}
@router.post("/", response_model=WorldResponse, status_code=201) @router.post("/")
async def create_world( async def create_world(
world_data: WorldCreate, world_data: dict,
current_user: CurrentUser, current_user: CurrentUser,
db: DbDep, db: DbDep,
): ):
"""Create a new world.""" """Create a new world."""
name = world_data.get("name")
if not name:
raise HTTPException(status_code=400, detail="World name is required")
world = World( world = World(
name=world_data.name, name=name,
description=world_data.description, description=world_data.get("description"),
owner_id=current_user.id, owner_id=current_user.id,
visibility=world_data.visibility, visibility=WorldVisibility(
world_data.get("visibility", "public")
),
) )
db.add(world) db.add(world)
await db.commit() await db.commit()
await db.refresh(world) await db.refresh(world)
return world
return {
"id": world.id,
"name": world.name,
"description": world.description,
"visibility": world.visibility.value,
"owner_id": world.owner_id,
"created_at": world.created_at.isoformat() if world.created_at else None,
}
@router.put("/{world_id}", response_model=WorldResponse) @router.put("/{world_id}")
async def update_world( async def update_world(
world_id: int, world_id: int,
world_data: WorldUpdate, world_data: dict,
current_user: CurrentUser, current_user: CurrentUser,
db: DbDep, db: DbDep,
): ):
@@ -134,16 +114,27 @@ async def update_world(
if world.owner_id != current_user.id: if world.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not owner of this world") raise HTTPException(status_code=403, detail="Not owner of this world")
update_data = world_data.model_dump(exclude_unset=True) if world_data.get("name"):
for field, value in update_data.items(): world.name = world_data["name"]
setattr(world, field, value) if world_data.get("description") is not None:
world.description = world_data["description"]
if world_data.get("visibility"):
world.visibility = WorldVisibility(world_data["visibility"])
await db.commit() await db.commit()
await db.refresh(world) await db.refresh(world)
return world
return {
"id": world.id,
"name": world.name,
"description": world.description,
"visibility": world.visibility.value if world.visibility else None,
"owner_id": world.owner_id,
"created_at": world.created_at.isoformat() if world.created_at else None,
}
@router.delete("/{world_id}", status_code=204) @router.delete("/{world_id}")
async def delete_world( async def delete_world(
world_id: int, world_id: int,
current_user: CurrentUser, current_user: CurrentUser,
@@ -160,3 +151,4 @@ async def delete_world(
await db.delete(world) await db.delete(world)
await db.commit() await db.commit()
return {"detail": "World deleted"}
-24
View File
@@ -1,24 +0,0 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; but it can also be edited by hand.
config_version=5
[application]
config/name="AGP-Game"
run/main_scene="res://scenes/main_menu.tscn"
config/features=PackedStringArray("4.6.2")
[autoload]
[display]
window/size/viewport_width=1920
window/size/viewport_height=1080
window/stretch/mode="canvas_items"
[rendering]
renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"
View File
-43
View File
@@ -1,43 +0,0 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/main_menu.gd" id="1_main"]
[node name="MainMenu" type="Control"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_main")
[node name="CenterContainer" type="CenterContainer" parent="."]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
layout_mode = 1
theme_override_constants/separation = 16
size_flags_horizontal = 4
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
layout_mode = 1
text = "AGP Game"
horizontal_alignment = 1
theme_override_font_sizes/font_size = 72
[node name="PlayButton" type="Button" parent="CenterContainer/VBoxContainer"]
layout_mode = 1
text = "Play"
size_flags_horizontal = 4
[node name="QuitButton" type="Button" parent="CenterContainer/VBoxContainer"]
layout_mode = 1
text = "Quit"
size_flags_horizontal = 4
[connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
View File
-14
View File
@@ -1,14 +0,0 @@
extends Control
func _ready():
pass
func _on_play_button_pressed():
# No gameplay scene exists yet — wire this to the real scene once one lands.
print("Play pressed — no gameplay scene yet")
func _on_quit_button_pressed():
get_tree().quit()