diff --git a/.commitlintrc.cjs b/.commitlintrc.cjs index 40efcad..50115a7 100644 --- a/.commitlintrc.cjs +++ b/.commitlintrc.cjs @@ -1,12 +1,91 @@ +// npx gitmoji -l +const gitmojis = [ + "⏪️", + "♻️", + "♿️", + "⚗️", + "⚡️", + "⚰️", + "✅", + "✏️", + "✨", + "➕", + "➖", + "⬆️", + "⬇️", + "🌐", + "🌱", + "🍱", + "🍻", + "🎉", + "🎨", + "🏗️", + "🏷️", + "🐛", + "👔", + "👥", + "👷", + "👽️", + "💄", + "💚", + "💡", + "💥", + "💩", + "💫", + "💬", + "💸", + "📄", + "📈", + "📌", + "📝", + "📦️", + "📱", + "📸", + "🔀", + "🔇", + "🔊", + "🔍️", + "🔐", + "🔒️", + "🔖", + "🔥", + "🔧", + "🔨", + "🗃️", + "🗑️", + "🙈", + "🚀", + "🚑️", + "🚚", + "🚧", + "🚨", + "🚩", + "🚸", + "🛂", + "🤡", + "🥅", + "🥚", + "🦺", + "🧐", + "🧑‍💻", + "🧪", + "🧱", + "🧵", + "🩹", + "🩺", +]; + module.exports = { - extends: ["gitmoji"], + extends: ["@commitlint/config-conventional"], parserPreset: { parserOpts: { - headerPattern: /^[^ ]+ (.*)$/, - headerCorrespondence: ["subject"], + headerPattern: /^([^ ]+) (.*)$/, + headerCorrespondence: ["type", "subject"], }, }, rules: { - "type-empty": [0, "always"], + "type-enum": [2, "always", gitmojis], + "type-case": [0, "always", "lower-case"], + "subject-full-stop": [2, "never", "."], }, }; diff --git a/.gitignore b/.gitignore index ed60c1b..6d5e708 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,9 @@ -**/*.egg-info -**/__pycache__/ +*.egg-info .env .ruff_cache/ .vscode/ /build/ /config.toml /data/*.sqlite3 +__pycache__/ node_modules/ diff --git a/Dockerfile b/Dockerfile index f8d86c4..5e98856 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,4 +24,4 @@ RUN <<-EOF pip install --no-cache -Ue . EOF -ENTRYPOINT [ "/usr/local/bin/python", "-m", "aethersprite.webapp" ] +ENTRYPOINT [ "/usr/local/bin/python", "-m", "realm_api" ] diff --git a/README.md b/README.md index ddbb547..b1382b0 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ pip install -U 'realm_api@git+https://github.com/realm-ttrpg/api-server.git' In the same directory as your `config.toml` file: ```shell -python -m aethersprite.webapp +python -m realm_api ``` [bot software]: https://github.com/realm-ttrpg/discord-bot diff --git a/package-lock.json b/package-lock.json index 784673b..6eefa1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "devDependencies": { + "@commitlint/config-conventional": "^19.8.1", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", "@semantic-release/github": "^11.0.1", @@ -123,6 +124,34 @@ "node": ">=v14" } }, + "node_modules/@commitlint/config-conventional": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-19.8.1.tgz", + "integrity": "sha512-/AZHJL6F6B/G959CsMAzrPKKZjeEiAVifRyEwXxcT6qtqbPwGw+iQxmNS+Bu+i09OCtdNRW6pNpBvgPrtMr9EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "conventional-changelog-conventionalcommits": "^7.0.2" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-conventional/node_modules/@commitlint/types": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-19.8.1.tgz", + "integrity": "sha512-/yCrWGCoA1SVKOks25EGadP9Pnj0oAIHGpl2wH2M2Y46dPM2ueb8wyCVOD7O3WCTkaJ0IkKvzhl1JY7+uCT2Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/conventional-commits-parser": "^5.0.0", + "chalk": "^5.3.0" + }, + "engines": { + "node": ">=v18" + } + }, "node_modules/@commitlint/config-validator": { "version": "17.8.1", "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-17.8.1.tgz", @@ -1647,6 +1676,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/conventional-commits-parser": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.1.tgz", + "integrity": "sha512-7uz5EHdzz2TqoMfV7ee61Egf5y6NkcO4FB/1iCCQnbeiI1F3xzv3vK5dBCXUCLQgGYS+mUeigK1iKQzvED+QnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/http-cache-semantics": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", @@ -2692,6 +2731,19 @@ "node": ">=18" } }, + "node_modules/conventional-changelog-conventionalcommits": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz", + "integrity": "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/conventional-changelog-writer": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-8.0.1.tgz", diff --git a/package.json b/package.json index 4967aa9..e05a9f9 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "devDependencies": { + "@commitlint/config-conventional": "^19.8.1", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", "@semantic-release/github": "^11.0.1", @@ -33,5 +34,6 @@ }, "scripts": { "prepare": "husky" - } + }, + "type": "module" } diff --git a/realm_api/__main__.py b/realm_api/__main__.py new file mode 100644 index 0000000..b6c53a2 --- /dev/null +++ b/realm_api/__main__.py @@ -0,0 +1,14 @@ +"""Entry point""" + +# 3rd party +from uvicorn import run + +# local +from os import environ + +run( + "realm_api.app:app", + host=environ.get("REALM_HOST", "0.0.0.0"), + port=int(environ.get("REALM_PORT", "5000")), + lifespan="on", +) diff --git a/realm_api/_all.py b/realm_api/_all.py deleted file mode 100644 index 58ea27c..0000000 --- a/realm_api/_all.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Realm API metaextension for Aethersprite""" - -META_EXTENSION = True -_mods = ["auth", "db", "rpc"] diff --git a/realm_api/api/__init__.py b/realm_api/api/__init__.py new file mode 100644 index 0000000..5c9b8c2 --- /dev/null +++ b/realm_api/api/__init__.py @@ -0,0 +1,15 @@ +"""API layer""" + +# 3rd party +from fastapi import APIRouter + +# local +from .auth.router import router as auth_router +from .game.router import router as game_router + + +router = APIRouter() +"""Main router""" + +router.include_router(auth_router) +router.include_router(game_router) diff --git a/realm_api/api/auth/__init__.py b/realm_api/api/auth/__init__.py new file mode 100644 index 0000000..d1928c4 --- /dev/null +++ b/realm_api/api/auth/__init__.py @@ -0,0 +1 @@ +"""Authentication/authorization""" diff --git a/realm_api/auth/depends.py b/realm_api/api/auth/depends.py similarity index 89% rename from realm_api/auth/depends.py rename to realm_api/api/auth/depends.py index 9cc461e..085b952 100644 --- a/realm_api/auth/depends.py +++ b/realm_api/api/auth/depends.py @@ -7,8 +7,8 @@ from sqlmodel import select # local -from ..db import get_session -from ..models.user_session import UserSession +from realm_api.db import get_session +from realm_api.models.user_session import UserSession async def require_login( diff --git a/realm_api/auth/routes.py b/realm_api/api/auth/router.py similarity index 89% rename from realm_api/auth/routes.py rename to realm_api/api/auth/router.py index 608ace5..34cb227 100644 --- a/realm_api/auth/routes.py +++ b/realm_api/api/auth/router.py @@ -9,10 +9,10 @@ from realm_schema import BotGuildsResponse # local -from ..db import get_session -from ..discord import DiscordClient -from ..models.user_session import UserSession -from ..rpc import redis_conn, rpc_bot +from realm_api.db import get_session +from realm_api.discord import DiscordClient +from realm_api.models.user_session import UserSession +from realm_api.rpc import redis_conn, rpc_bot from .depends import require_login from .schema import LoginRequest, LoginResponse, SharedGuildsResponse @@ -78,7 +78,7 @@ async def shared_guilds( discord = DiscordClient(session.user_id, session.discord_token) my_guilds = await discord.get_guilds() - if cached := redis_conn.get("bot.guilds"): + if cached := await redis_conn.get("bot.guilds"): bot_guilds = ( BotGuildsResponse.model_validate_json(cached) # type: ignore ) @@ -86,7 +86,7 @@ async def shared_guilds( bot_guilds = BotGuildsResponse.model_validate_json( await rpc_bot("guilds") ) - redis_conn.setex( + await redis_conn.setex( "bot.guilds", CACHE_EXPIRY, bot_guilds.model_dump_json() ) diff --git a/realm_api/auth/schema.py b/realm_api/api/auth/schema.py similarity index 96% rename from realm_api/auth/schema.py rename to realm_api/api/auth/schema.py index a29c587..0f0da0d 100644 --- a/realm_api/auth/schema.py +++ b/realm_api/api/auth/schema.py @@ -1,5 +1,6 @@ """HTTP request/response schema for authz/authn routes""" +# 3rd party from pydantic import BaseModel diff --git a/realm_api/api/game/__init__.py b/realm_api/api/game/__init__.py new file mode 100644 index 0000000..231e87c --- /dev/null +++ b/realm_api/api/game/__init__.py @@ -0,0 +1 @@ +"""Game module""" diff --git a/realm_api/api/game/depends.py b/realm_api/api/game/depends.py new file mode 100644 index 0000000..748f75a --- /dev/null +++ b/realm_api/api/game/depends.py @@ -0,0 +1,32 @@ +"""FastAPI checks for use in `Depends()`""" + +# 3rd party +from fastapi import Depends, HTTPException, Path, status +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +# local +from realm_api.api.auth.depends import require_login +from realm_api.db import get_session +from realm_api.models.game import Game +from realm_api.models.game_player import GamePlayer +from realm_api.models.user_session import UserSession + + +async def user_in_guild( + guild_id: int = Path(), + session: UserSession = Depends(require_login), + db: AsyncSession = Depends(get_session), +): + if not ( + await db.exec( + select(1) + .select_from(GamePlayer, Game) + .where( + Game.guild_id == str(guild_id), + GamePlayer.game_id == Game.id, + GamePlayer.player_id == session.user_id, + ) + ) + ).one_or_none(): + raise HTTPException(status.HTTP_403_FORBIDDEN) diff --git a/realm_api/api/game/router.py b/realm_api/api/game/router.py new file mode 100644 index 0000000..b659a08 --- /dev/null +++ b/realm_api/api/game/router.py @@ -0,0 +1,44 @@ +"""Game routes""" + +# 3rd party +from fastapi import APIRouter, Depends +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +# local +from realm_api.db import get_session +from realm_api.models.game import Game +from .depends import user_in_guild +from .schema import GameResponse + +router = APIRouter(prefix="/game") + + +@router.get("/list/{guild_id}") +async def list_games( + guild_id: int, + db: AsyncSession = Depends(get_session), + in_guild=Depends(user_in_guild), +) -> list[GameResponse]: + games = ( + await db.exec(select(Game).where(Game.guild_id == str(guild_id))) + ).all() + response = [ + GameResponse.model_validate(game, from_attributes=True) + for game in games + ] + + return response + + +@router.get("/{guild_id}/{game_id}") +async def get_game( + guild_id: int, + game_id: int, + db: AsyncSession = Depends(get_session), + in_guild=Depends(user_in_guild), +) -> GameResponse: + game = (await db.exec(select(Game).where(Game.id == str(game_id)))).one() + response = GameResponse.model_validate(game, from_attributes=True) + + return response diff --git a/realm_api/api/game/schema.py b/realm_api/api/game/schema.py new file mode 100644 index 0000000..510ac5f --- /dev/null +++ b/realm_api/api/game/schema.py @@ -0,0 +1,9 @@ +"""HTTP request/response schema for game routes""" + +# 3rd party +from pydantic import BaseModel + + +class GameResponse(BaseModel): + id: int + name: str diff --git a/realm_api/app.py b/realm_api/app.py new file mode 100644 index 0000000..c78edd6 --- /dev/null +++ b/realm_api/app.py @@ -0,0 +1,36 @@ +"""FastAPI application""" + +# stdlib +from contextlib import asynccontextmanager + +# 3rd party +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +# local +from .api import router +from .db import init_db +from .rpc import init_pubsub, shutdown_pubsub + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Lifespan function: startup -> yield -> shutdown""" + + await init_db() + task = await init_pubsub() + yield + await shutdown_pubsub(task) + + +app = FastAPI(lifespan=lifespan) +"""Web application""" + +app.add_middleware( + CORSMiddleware, + allow_credentials=True, + allow_headers=["*"], + allow_methods=["*"], + allow_origins=["*"], +) +app.include_router(router) diff --git a/realm_api/auth/__init__.py b/realm_api/auth/__init__.py deleted file mode 100644 index b48d2f4..0000000 --- a/realm_api/auth/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Authentication/authorization""" - -# 3rd party -from fastapi import APIRouter, FastAPI - -# local -from .routes import router - - -def setup_webapp(_app: FastAPI, app_router: APIRouter): - app_router.include_router(router) diff --git a/realm_api/cors.py b/realm_api/cors.py deleted file mode 100644 index d1be827..0000000 --- a/realm_api/cors.py +++ /dev/null @@ -1,15 +0,0 @@ -"""CORS extension""" - -# 3rd party -from fastapi import APIRouter, FastAPI -from fastapi.middleware.cors import CORSMiddleware - - -def setup_webapp(app: FastAPI, _router: APIRouter): - app.add_middleware( - CORSMiddleware, - allow_credentials=True, - allow_headers=["*"], - allow_methods=["*"], - allow_origins=["*"], - ) diff --git a/realm_api/db.py b/realm_api/db.py index 64419d8..e8f119d 100644 --- a/realm_api/db.py +++ b/realm_api/db.py @@ -1,17 +1,16 @@ -"""Database functionality and Aethersprite extension""" +"""Database functionality""" # stdlib -import asyncio as aio import os # 3rd party -from fastapi import FastAPI from sqlmodel import SQLModel from sqlalchemy.ext.asyncio import create_async_engine from sqlmodel.ext.asyncio.session import AsyncSession -# api -from aethersprite import log +# local +from realm_api.logging import logger + DB_URL = os.environ.get( "DB_URL", "postgresql+asyncpg://realm:realm@localhost/realm" @@ -19,31 +18,15 @@ async_engine = create_async_engine(DB_URL, future=True) -class StartupMiddleware: - """Startup middleware for initializing the database""" - - initialized: aio.Event = aio.Event() - - def __init__(self, app): - self.app = app - - @classmethod - async def init_db(cls): - """Initializes the database.""" +async def init_db(): + """Initialize the database. Used in the FastAPI application lifespan.""" - from .models.user_session import UserSession # noqa: F401 + from . import models # noqa: F401 - log.info("Initializing database") + logger.info("Initializing database") - async with async_engine.begin() as conn: - await conn.run_sync(SQLModel.metadata.create_all) - - async def __call__(self, scope, receive, send): - if not StartupMiddleware.initialized.is_set(): - await StartupMiddleware.init_db() - StartupMiddleware.initialized.set() - - await self.app(scope, receive, send) + async with async_engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) async def get_session(): @@ -51,7 +34,3 @@ async def get_session(): async with AsyncSession(async_engine) as session: yield session - - -def setup_webapp(app: FastAPI, *_): - app.add_middleware(StartupMiddleware) diff --git a/realm_api/logging.py b/realm_api/logging.py new file mode 100644 index 0000000..2150c35 --- /dev/null +++ b/realm_api/logging.py @@ -0,0 +1,12 @@ +"""Logging""" + +# stdlib +from logging import getLogger, StreamHandler +from os import environ + + +logger = getLogger() +"""Root logger""" + +logger.addHandler(StreamHandler()) +logger.setLevel(environ.get("LOG_LEVEL", "INFO")) diff --git a/realm_api/models/__init__.py b/realm_api/models/__init__.py index a8fac9a..94149d5 100644 --- a/realm_api/models/__init__.py +++ b/realm_api/models/__init__.py @@ -1 +1,16 @@ """SQLModel models""" + +# ruff: noqa: F401 +from .character import Character +from .character_prop import CharacterProp +from .character_stat import CharacterStat +from .game import Game +from .game_player import GamePlayer +from .game_player_character import GamePlayerCharacter +from .game_player_role import GamePlayerRole +from .game_role import GameRole +from .guild import Guild +from .player import Player +from .role import Role +from .system import System +from .user_session import UserSession diff --git a/realm_api/models/character.py b/realm_api/models/character.py new file mode 100644 index 0000000..0cc6a14 --- /dev/null +++ b/realm_api/models/character.py @@ -0,0 +1,30 @@ +"""Character model""" + +# stdlib +from typing import TYPE_CHECKING + +# 3rd party +from sqlmodel import Field, Relationship, SQLModel + +# local +from .character_prop import CharacterProp +from .character_stat import CharacterStat +from .game_player_character import GamePlayerCharacter + +if TYPE_CHECKING: + from .game_player import GamePlayer + + +class Character(SQLModel, table=True): + id: int = Field(primary_key=True) + game_id: int = Field(foreign_key="game.id") + managers: list["GamePlayer"] = Relationship( + back_populates="characters", + link_model=GamePlayerCharacter, + sa_relationship_kwargs={ + "primaryjoin": "Character.id == GamePlayerCharacter.character_id", + "secondaryjoin": "GamePlayerCharacter.player_id == GamePlayer.player_id", + }, + ) + props: list[CharacterProp] = Relationship(back_populates="character") + stats: list[CharacterStat] = Relationship(back_populates="character") diff --git a/realm_api/models/character_prop.py b/realm_api/models/character_prop.py new file mode 100644 index 0000000..c9f44fc --- /dev/null +++ b/realm_api/models/character_prop.py @@ -0,0 +1,19 @@ +"""Character property model""" + +# stdlib +from typing import TYPE_CHECKING + +# 3rd party +from sqlmodel import Field, Relationship, SQLModel + +if TYPE_CHECKING: + from .character import Character + + +class CharacterProp(SQLModel, table=True): + __tablename__ = "character_prop" # type: ignore + + character_id: int = Field(foreign_key="character.id", primary_key=True) + character: "Character" = Relationship(back_populates="props") + name: str = Field(primary_key=True, max_length=32) + value: str = Field() diff --git a/realm_api/models/character_stat.py b/realm_api/models/character_stat.py new file mode 100644 index 0000000..dc87473 --- /dev/null +++ b/realm_api/models/character_stat.py @@ -0,0 +1,19 @@ +"""Character stat model""" + +# stdlib +from typing import TYPE_CHECKING + +# 3rd party +from sqlmodel import Field, Relationship, SQLModel + +if TYPE_CHECKING: + from .character import Character + + +class CharacterStat(SQLModel, table=True): + __tablename__ = "character_stat" # type: ignore + + character_id: int = Field(foreign_key="character.id", primary_key=True) + character: "Character" = Relationship(back_populates="stats") + name: str = Field(primary_key=True, max_length=32) + value: int = Field() diff --git a/realm_api/models/game.py b/realm_api/models/game.py new file mode 100644 index 0000000..67dfedc --- /dev/null +++ b/realm_api/models/game.py @@ -0,0 +1,24 @@ +"""Game model""" + +# stdlib +from typing import TYPE_CHECKING + +# 3rd party +from sqlmodel import Field, Relationship, SQLModel + +# local +from .system import System + +if TYPE_CHECKING: + from .game_player import GamePlayer + from .guild import Guild + + +class Game(SQLModel, table=True): + id: int = Field(primary_key=True) + system_id: int = Field(foreign_key="system.id") + system: System = Relationship(back_populates="games") + guild_id: str = Field(foreign_key="guild.id") + guild: "Guild" = Relationship(back_populates="games") + name: str = Field(max_length=128) + players: list["GamePlayer"] = Relationship(back_populates="game") diff --git a/realm_api/models/game_player.py b/realm_api/models/game_player.py new file mode 100644 index 0000000..27321ec --- /dev/null +++ b/realm_api/models/game_player.py @@ -0,0 +1,42 @@ +"""Game Player model""" + +# stdlib +from typing import TYPE_CHECKING + +# 3rd party +from sqlmodel import Field, Relationship, SQLModel + +# local +from .character import Character +from .game import Game +from .game_player_character import GamePlayerCharacter +from .game_player_role import GamePlayerRole + +if TYPE_CHECKING: + from .game_role import GameRole + from .player import Player + + +class GamePlayer(SQLModel, table=True): + __tablename__ = "game_player" # type: ignore + + game_id: int = Field(foreign_key="game.id", primary_key=True) + game: Game = Relationship(back_populates="players") + player_id: str = Field(foreign_key="player.user_id", primary_key=True) + player: "Player" = Relationship() + characters: list[Character] = Relationship( + back_populates="managers", + link_model=GamePlayerCharacter, + sa_relationship_kwargs={ + "primaryjoin": "GamePlayer.player_id == GamePlayerCharacter.player_id", + "secondaryjoin": "GamePlayerCharacter.character_id == Character.id", + }, + ) + roles: list["GameRole"] = Relationship( + back_populates="members", + link_model=GamePlayerRole, + sa_relationship_kwargs={ + "primaryjoin": "GamePlayer.player_id == GamePlayerRole.player_id", + "secondaryjoin": "GamePlayerRole.role_id == GameRole.role_id", + }, + ) diff --git a/realm_api/models/game_player_character.py b/realm_api/models/game_player_character.py new file mode 100644 index 0000000..2dc8a3c --- /dev/null +++ b/realm_api/models/game_player_character.py @@ -0,0 +1,12 @@ +"""GamePlayer -> Character model""" + +# 3rd party +from sqlmodel import Field, SQLModel + + +class GamePlayerCharacter(SQLModel, table=True): + __tablename__ = "game_player_character" # type: ignore + + game_id: int = Field(foreign_key="game.id", primary_key=True) + character_id: int = Field(foreign_key="character.id", primary_key=True) + player_id: str = Field(foreign_key="player.user_id", primary_key=True) diff --git a/realm_api/models/game_player_role.py b/realm_api/models/game_player_role.py new file mode 100644 index 0000000..e6a588d --- /dev/null +++ b/realm_api/models/game_player_role.py @@ -0,0 +1,12 @@ +"""GamePlayer -> GameRole model""" + +# 3rd party +from sqlmodel import Field, SQLModel + + +class GamePlayerRole(SQLModel, table=True): + __tablename__ = "game_player_role" # type: ignore + + game_id: int = Field(foreign_key="game.id", primary_key=True) + role_id: int = Field(foreign_key="role.id", primary_key=True) + player_id: str = Field(foreign_key="player.user_id", primary_key=True) diff --git a/realm_api/models/game_role.py b/realm_api/models/game_role.py new file mode 100644 index 0000000..317ea8e --- /dev/null +++ b/realm_api/models/game_role.py @@ -0,0 +1,25 @@ +"""Game Role model""" + +# 3rd party +from sqlmodel import Field, Relationship, SQLModel + +# local +from .game import Game +from .game_player import GamePlayer +from .game_player_role import GamePlayerRole + + +class GameRole(SQLModel, table=True): + __tablename__ = "game_role" # type: ignore + + game_id: int = Field(foreign_key="game.id", primary_key=True) + game: Game = Relationship() + role_id: int = Field(foreign_key="role.id", primary_key=True) + members: list[GamePlayer] = Relationship( + back_populates="roles", + link_model=GamePlayerRole, + sa_relationship_kwargs={ + "primaryjoin": "GameRole.role_id == GamePlayerRole.role_id", + "secondaryjoin": "GamePlayerRole.player_id == GamePlayer.player_id", + }, + ) diff --git a/realm_api/models/guild.py b/realm_api/models/guild.py new file mode 100644 index 0000000..3f4c6d5 --- /dev/null +++ b/realm_api/models/guild.py @@ -0,0 +1,12 @@ +"""Discord Guild model""" + +# 3rd party +from sqlmodel import Field, Relationship, SQLModel + +# local +from .game import Game + + +class Guild(SQLModel, table=True): + id: str = Field(primary_key=True, max_length=32) + games: list[Game] = Relationship(back_populates="guild") diff --git a/realm_api/models/player.py b/realm_api/models/player.py new file mode 100644 index 0000000..750de0d --- /dev/null +++ b/realm_api/models/player.py @@ -0,0 +1,21 @@ +"""Player model""" + +# 3rd party +from sqlmodel import Field, Relationship, SQLModel + +# local +from .game import Game +from .game_player import GamePlayer + + +class Player(SQLModel, table=True): + user_id: str = Field(primary_key=True, max_length=32) + name: str = Field(max_length=32) + games: list[Game] = Relationship( + link_model=GamePlayer, + sa_relationship_kwargs={ + "primaryjoin": "Player.user_id == GamePlayer.player_id", + "secondaryjoin": "GamePlayer.game_id == Game.id", + "overlaps": "game,player,players", + }, + ) diff --git a/realm_api/models/role.py b/realm_api/models/role.py new file mode 100644 index 0000000..1a8e724 --- /dev/null +++ b/realm_api/models/role.py @@ -0,0 +1,26 @@ +"""Role model""" + +# stdlib +from typing import TYPE_CHECKING + +# 3rd party +from sqlmodel import Field, Relationship, SQLModel + +# local +from .game_player_role import GamePlayerRole + +if TYPE_CHECKING: + from .player import Player + + +class Role(SQLModel, table=True): + id: int = Field(primary_key=True) + name: str = Field(max_length=32) + players: list["Player"] = Relationship( + link_model=GamePlayerRole, + sa_relationship_kwargs={ + "primaryjoin": "Role.id == GamePlayerRole.role_id", + "secondaryjoin": "GamePlayerRole.player_id == Player.user_id", + "overlaps": "members,roles", + }, + ) diff --git a/realm_api/models/system.py b/realm_api/models/system.py new file mode 100644 index 0000000..917d9f0 --- /dev/null +++ b/realm_api/models/system.py @@ -0,0 +1,17 @@ +"""Game system model""" + +# stdlib +from typing import TYPE_CHECKING + +# 3rd party +from sqlmodel import Field, Relationship, SQLModel + +if TYPE_CHECKING: + from .game import Game + + +class System(SQLModel, table=True): + id: int = Field(primary_key=True) + name: str = Field(max_length=128) + slug: str = Field(max_length=16, unique=True) + games: list["Game"] = Relationship(back_populates="system") diff --git a/realm_api/models/user_session.py b/realm_api/models/user_session.py index cfc294f..daca3c1 100644 --- a/realm_api/models/user_session.py +++ b/realm_api/models/user_session.py @@ -12,6 +12,8 @@ def uuid_factory(): class UserSession(SQLModel, table=True): + __tablename__ = "user_session" # type: ignore + user_id: str = Field(primary_key=True, max_length=32) discord_token: str = Field(max_length=64) realm_token: str = Field( diff --git a/realm_api/rpc/__init__.py b/realm_api/rpc/__init__.py index f132b5b..1f6d046 100644 --- a/realm_api/rpc/__init__.py +++ b/realm_api/rpc/__init__.py @@ -7,26 +7,35 @@ from uuid import uuid4 # 3rd party -import redis - -# api -from aethersprite import log +from redis.asyncio import StrictRedis # local +from realm_api.logging import logger from .roll import roll_handler -redis_conn = redis.StrictRedis(host=os.environ.get("REDIS_HOST", "localhost")) +redis_conn = StrictRedis(host=os.environ.get("REDIS_HOST", "localhost")) pubsub = redis_conn.pubsub(ignore_subscribe_messages=True) handlers = { "roll": roll_handler, } +async def init_pubsub() -> aio.Task: + await pubsub.subscribe(**{"rpc.api": handler}) + return aio.create_task(pubsub.run()) + + +async def shutdown_pubsub(task: aio.Task): + pubsub.unsubscribe() + await pubsub.close() + task.cancel() + + def handler(message: dict): """Handle an incoming RPC operation and publish the result.""" data: dict = json.loads(message["data"]) - log.info(f"RPC op: {data['op']}") + logger.info(f"RPC op: {data['op']}") result = handlers[data["op"]]( *data.get("args", []), **data.get("kwargs", dict()), @@ -39,37 +48,22 @@ async def rpc_bot(op: str, *args, timeout=3, **kwargs): q = aio.Queue() - def handler(message: dict): + async def handler(message: dict): data = message["data"] - aio.new_event_loop().run_until_complete(q.put(data)) + await q.put(data) uuid = str(uuid4()) - pubsub.subscribe(**{uuid: handler}) + await pubsub.subscribe(**{uuid: handler}) + message = { + "uuid": uuid, + "op": op, + "args": args, + "kwargs": kwargs, + } try: - redis_conn.publish( - "rpc.bot", - json.dumps( - { - "uuid": uuid, - "op": op, - "args": args, - "kwargs": kwargs, - } - ), - ) - + await redis_conn.publish("rpc.bot", json.dumps(message)) return await aio.wait_for(q.get(), timeout) finally: - pubsub.unsubscribe(uuid) - - -def setup_webapp(*_): - pubsub.subscribe(**{"rpc.api": handler}) - pubsub.run_in_thread(daemon=True, sleep_time=0.01) - - -def teardown_webapp(*_): - pubsub.unsubscribe() - pubsub.close() + await pubsub.unsubscribe(uuid) diff --git a/requirements/dev.txt b/requirements/dev.txt index 6f03754..c00267f 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -4,21 +4,21 @@ # # pip-compile dev.in # -build==1.2.2.post1 +build==1.3.0 # via pip-tools -click==8.1.7 +click==8.2.1 # via # -c requirements.txt # pip-tools -packaging==24.2 +packaging==25.0 # via build -pip-tools==7.4.1 +pip-tools==7.5.0 # via -r dev.in pyproject-hooks==1.2.0 # via # build # pip-tools -ruff==0.9.10 +ruff==0.12.10 # via -r dev.in wheel==0.45.1 # via pip-tools diff --git a/requirements/docs.txt b/requirements/docs.txt index cf4f228..c22cb9d 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -4,11 +4,11 @@ # # pip-compile docs.in # -mako==1.3.9 +mako==1.3.10 # via pdoc3 -markdown==3.7 +markdown==3.8.2 # via pdoc3 markupsafe==3.0.2 # via mako -pdoc3==0.11.5 +pdoc3==0.11.6 # via -r docs.in diff --git a/requirements/requirements.in b/requirements/requirements.in index 49deaef..d84e0c9 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -1,7 +1,9 @@ -aethersprite@git+https://github.com/haliphax/aethersprite.git@main +aiohttp asyncpg +fastapi hiredis realm_schema@git+https://github.com/realm-ttrpg/schema.git@main redis sqlalchemy[asyncio] sqlmodel +uvicorn[standard] diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 5f6c6d6..d067be4 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -4,144 +4,94 @@ # # pip-compile requirements.in # -aethersprite @ git+https://github.com/haliphax/aethersprite.git@main - # via -r requirements.in aiohappyeyeballs==2.6.1 - # via - # aethersprite - # aiohttp -aiohttp==3.12.14 - # via - # aethersprite - # discord-py + # via aiohttp +aiohttp==3.12.15 + # via -r requirements.in aiosignal==1.4.0 + # via aiohttp +annotated-types==0.7.0 + # via pydantic +anyio==4.10.0 # via - # aethersprite - # aiohttp -annotated-types==0.6.0 - # via - # aethersprite - # pydantic -anyio==4.3.0 - # via - # aethersprite # starlette # watchfiles asyncpg==0.30.0 # via -r requirements.in -attrs==23.2.0 - # via - # aethersprite - # aiohttp -click==8.1.7 - # via - # aethersprite - # uvicorn -colorlog==6.8.2 - # via aethersprite -discord-pretty-help==2.0.7 - # via aethersprite -discord-py==2.3.2 - # via aethersprite -fastapi==0.110.0 - # via aethersprite -frozenlist==1.4.1 +attrs==25.3.0 + # via aiohttp +click==8.2.1 + # via uvicorn +fastapi==0.116.1 + # via -r requirements.in +frozenlist==1.7.0 # via - # aethersprite # aiohttp # aiosignal -greenlet==3.1.1 +greenlet==3.2.4 # via sqlalchemy h11==0.16.0 - # via - # aethersprite - # uvicorn -hiredis==3.1.0 + # via uvicorn +hiredis==3.2.1 # via -r requirements.in -httptools==0.6.1 +httptools==0.6.4 + # via uvicorn +idna==3.10 # via - # aethersprite - # uvicorn -idna==3.7 - # via - # aethersprite # anyio # yarl -multidict==6.0.5 +multidict==6.6.4 # via - # aethersprite # aiohttp # yarl -propcache==0.2.0 +propcache==0.3.2 # via - # aethersprite # aiohttp # yarl -pydantic==2.6.4 +pydantic==2.11.7 # via - # aethersprite # fastapi # realm-schema # sqlmodel -pydantic-core==2.16.3 - # via - # aethersprite - # pydantic -python-dotenv==1.0.1 - # via - # aethersprite - # uvicorn -pytz==2024.1 - # via aethersprite -pyyaml==6.0.1 - # via - # aethersprite - # uvicorn +pydantic-core==2.33.2 + # via pydantic +python-dotenv==1.1.1 + # via uvicorn +pyyaml==6.0.2 + # via uvicorn realm-schema @ git+https://github.com/realm-ttrpg/schema.git@main # via -r requirements.in -redis==5.2.1 +redis==6.4.0 # via -r requirements.in sniffio==1.3.1 - # via - # aethersprite - # anyio -sqlalchemy[asyncio]==2.0.39 + # via anyio +sqlalchemy[asyncio]==2.0.43 # via # -r requirements.in # sqlmodel -sqlitedict==2.1.0 - # via aethersprite sqlmodel==0.0.24 # via -r requirements.in -starlette==0.36.3 - # via - # aethersprite - # fastapi -toml==0.10.2 - # via aethersprite -typing-extensions==4.10.0 +starlette==0.47.3 + # via fastapi +typing-extensions==4.15.0 # via - # aethersprite # aiosignal + # anyio # fastapi # pydantic # pydantic-core # sqlalchemy -uvicorn[standard]==0.29.0 - # via aethersprite -uvloop==0.19.0 - # via - # aethersprite - # uvicorn -watchfiles==0.21.0 - # via - # aethersprite - # uvicorn -websockets==12.0 - # via - # aethersprite - # uvicorn -yarl==1.17.2 - # via - # aethersprite - # aiohttp + # starlette + # typing-inspection +typing-inspection==0.4.1 + # via pydantic +uvicorn[standard]==0.35.0 + # via -r requirements.in +uvloop==0.21.0 + # via uvicorn +watchfiles==1.1.0 + # via uvicorn +websockets==15.0.1 + # via uvicorn +yarl==1.20.1 + # via aiohttp