commit 4d51beb3505ba4c7e9a4568977f5c724e77fd785 Author: itqop Date: Sat Oct 11 01:14:16 2025 +0300 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0368a70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,65 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +logs/ +*.log + +# Database +*.db +*.sqlite3 + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ + +# Poetry +poetry.lock diff --git a/API_EXAMPLES.md b/API_EXAMPLES.md new file mode 100644 index 0000000..2f741ea --- /dev/null +++ b/API_EXAMPLES.md @@ -0,0 +1,282 @@ +# API Examples + +Примеры использования API для HubGW. + +## Аутентификация + +Все запросы (кроме `/health`) требуют заголовок `X-API-Key` с правильным API ключом. + +```bash +curl -H "X-API-Key: your-secret-api-key" \ + -H "Content-Type: application/json" \ + http://localhost:8080/api/v1/health/ +``` + +## Homes + +### Создать/обновить дом +```bash +curl -X PUT \ + -H "X-API-Key: your-secret-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "player_uuid": "123e4567-e89b-12d3-a456-426614174000", + "name": "my_home", + "world": "world", + "x": 100.5, + "y": 64.0, + "z": 200.3, + "yaw": 90.0, + "pitch": 0.0, + "is_public": 0 + }' \ + http://localhost:8080/api/v1/homes/ +``` + +### Получить дом +```bash +curl -X POST \ + -H "X-API-Key: your-secret-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "player_uuid": "123e4567-e89b-12d3-a456-426614174000", + "name": "my_home" + }' \ + http://localhost:8080/api/v1/homes/get +``` + +### Список домов игрока +```bash +curl -H "X-API-Key: your-secret-api-key" \ + http://localhost:8080/api/v1/homes/123e4567-e89b-12d3-a456-426614174000 +``` + +## Kits + +### Запросить набор +```bash +curl -X POST \ + -H "X-API-Key: your-secret-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "player_uuid": "123e4567-e89b-12d3-a456-426614174000", + "kit_name": "starter" + }' \ + http://localhost:8080/api/v1/kits/claim +``` + +## Cooldowns + +### Проверить кулдаун +```bash +curl -X POST \ + -H "X-API-Key: your-secret-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "player_uuid": "123e4567-e89b-12d3-a456-426614174000", + "key": "kit_starter" + }' \ + http://localhost:8080/api/v1/cooldowns/check +``` + +### Установить кулдаун +```bash +curl -X PUT \ + -H "X-API-Key: your-secret-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "key": "kit_starter" + }' \ + "http://localhost:8080/api/v1/cooldowns/touch?seconds=3600&player_uuid=123e4567-e89b-12d3-a456-426614174000" +``` + +## Warps + +### Создать варп +```bash +curl -X POST \ + -H "X-API-Key: your-secret-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "spawn", + "world": "world", + "x": 0.0, + "y": 64.0, + "z": 0.0, + "yaw": 0.0, + "pitch": 0.0, + "is_public": 1, + "description": "Main spawn point" + }' \ + http://localhost:8080/api/v1/warps/ +``` + +### Обновить варп +```bash +curl -X PATCH \ + -H "X-API-Key: your-secret-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "spawn", + "world": "world_nether", + "description": "Updated spawn point" + }' \ + http://localhost:8080/api/v1/warps/ +``` + +### Удалить варп +```bash +curl -X DELETE \ + -H "X-API-Key: your-secret-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "spawn" + }' \ + http://localhost:8080/api/v1/warps/ +``` + +### Получить варп +```bash +curl -X POST \ + -H "X-API-Key: your-secret-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "spawn" + }' \ + http://localhost:8080/api/v1/warps/get +``` + +### Список варпов +```bash +curl -X POST \ + -H "X-API-Key: your-secret-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "page": 1, + "size": 20, + "world": "world", + "is_public": 1 + }' \ + http://localhost:8080/api/v1/warps/list +``` + +## Whitelist + +### Добавить игрока в вайтлист +```bash +curl -X POST \ + -H "X-API-Key: your-secret-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "player_name": "PlayerName", + "player_uuid": "123e4567-e89b-12d3-a456-426614174000", + "added_by": "admin", + "reason": "VIP player" + }' \ + http://localhost:8080/api/v1/whitelist/add +``` + +### Удалить игрока из вайтлиста +```bash +curl -X POST \ + -H "X-API-Key: your-secret-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "player_name": "PlayerName" + }' \ + http://localhost:8080/api/v1/whitelist/remove +``` + +### Проверить вайтлист +```bash +curl -X POST \ + -H "X-API-Key: your-secret-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "player_name": "PlayerName" + }' \ + http://localhost:8080/api/v1/whitelist/check +``` + +### Список вайтлиста +```bash +curl -H "X-API-Key: your-secret-api-key" \ + http://localhost:8080/api/v1/whitelist/ +``` + +## Punishments + +### Создать наказание +```bash +curl -X POST \ + -H "X-API-Key: your-secret-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "player_uuid": "123e4567-e89b-12d3-a456-426614174000", + "player_name": "PlayerName", + "punishment_type": "ban", + "reason": "Cheating", + "staff_uuid": "987fcdeb-51a2-43d1-b456-426614174000", + "staff_name": "Admin", + "expires_at": "2024-12-31T23:59:59Z" + }' \ + http://localhost:8080/api/v1/punishments/ +``` + +### Отменить наказание +```bash +curl -X POST \ + -H "X-API-Key: your-secret-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "punishment_id": "456e7890-e89b-12d3-a456-426614174000", + "revoked_by": "987fcdeb-51a2-43d1-b456-426614174000", + "revoked_reason": "Appeal accepted" + }' \ + http://localhost:8080/api/v1/punishments/revoke +``` + +### Запрос наказаний +```bash +curl -X POST \ + -H "X-API-Key: your-secret-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "player_uuid": "123e4567-e89b-12d3-a456-426614174000", + "punishment_type": "ban", + "is_active": true, + "page": 1, + "size": 20 + }' \ + http://localhost:8080/api/v1/punishments/query +``` + +### Статус бана +```bash +curl -H "X-API-Key: your-secret-api-key" \ + http://localhost:8080/api/v1/punishments/ban/123e4567-e89b-12d3-a456-426614174000 +``` + +### Статус мута +```bash +curl -H "X-API-Key: your-secret-api-key" \ + http://localhost:8080/api/v1/punishments/mute/123e4567-e89b-12d3-a456-426614174000 +``` + +## Audit + +### Логирование команды +```bash +curl -X POST \ + -H "X-API-Key: your-secret-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "player_uuid": "123e4567-e89b-12d3-a456-426614174000", + "player_name": "PlayerName", + "command": "tp", + "arguments": ["player2"], + "server": "hub", + "timestamp": "2024-01-01T12:00:00Z" + }' \ + http://localhost:8080/api/v1/audit/commands +``` diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..7b4f160 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,156 @@ +# Развертывание HubGW + +Инструкции по развертыванию FastAPI-шлюза HubGW. + +## Требования + +- Python 3.11+ +- PostgreSQL 12+ +- Poetry (для управления зависимостями) + +## Установка + +1. Клонируйте репозиторий: +```bash +git clone +cd hubmc-datagw +``` + +2. Установите зависимости: +```bash +poetry install +``` + +3. Создайте файл `.env` на основе `.env.example`: +```bash +cp .env.example .env +``` + +4. Настройте переменные окружения в `.env`: +```env +APP_ENV=prod +APP_HOST=0.0.0.0 +APP_PORT=8080 +APP_LOG_LEVEL=INFO + +DB_DSN=postgresql+asyncpg://user:password@localhost:5432/hubgw +DB_POOL_SIZE=10 +DB_MAX_OVERFLOW=10 + +API_KEY=your-secure-api-key-here +RATE_LIMIT_PER_MIN=1000 +``` + +## Настройка базы данных + +1. Создайте базу данных PostgreSQL: +```sql +CREATE DATABASE hubgw; +CREATE USER hubgw_user WITH PASSWORD 'secure_password'; +GRANT ALL PRIVILEGES ON DATABASE hubgw TO hubgw_user; +``` + +2. Создайте таблицы (в будущем будет добавлена миграция): +```sql +-- Таблицы будут созданы автоматически при первом запуске +-- или можно использовать Alembic для миграций +``` + +## Запуск + +### Разработка +```bash +poetry run hubgw +``` + +### Продакшн с Gunicorn +```bash +poetry run gunicorn hubgw.main:create_app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8080 +``` + +### Продакшн с Docker +```dockerfile +FROM python:3.11-slim + +WORKDIR /app + +# Установка Poetry +RUN pip install poetry + +# Копирование файлов +COPY pyproject.toml poetry.lock ./ +COPY src/ ./src/ + +# Установка зависимостей +RUN poetry config virtualenvs.create false +RUN poetry install --no-dev + +# Запуск приложения +CMD ["poetry", "run", "hubgw"] +``` + +## Мониторинг + +### Логи +Логи сохраняются в директории `logs/` в продакшне: +- `logs/hubgw.log` - основные логи приложения +- Ротация каждый день +- Хранение 30 дней +- Сжатие старых логов + +### Health Check +```bash +curl http://localhost:8080/api/v1/health/ +``` + +## Безопасность + +1. **API Key**: Используйте сильный, случайный API ключ +2. **HTTPS**: Настройте SSL/TLS в продакшне +3. **Firewall**: Ограничьте доступ к порту 8080 +4. **База данных**: Используйте отдельного пользователя БД с минимальными правами + +## Масштабирование + +### Горизонтальное масштабирование +- Используйте load balancer (nginx, HAProxy) +- Настройте sticky sessions если необходимо +- Используйте внешний Redis для сессий + +### Вертикальное масштабирование +- Увеличьте `DB_POOL_SIZE` и `DB_MAX_OVERFLOW` +- Настройте `RATE_LIMIT_PER_MIN` под нагрузку +- Мониторьте использование памяти и CPU + +## Резервное копирование + +1. **База данных**: Регулярные бэкапы PostgreSQL +2. **Конфигурация**: Сохраните файл `.env` в безопасном месте +3. **Логи**: Архивируйте важные логи + +## Обновление + +1. Остановите приложение +2. Создайте бэкап базы данных +3. Обновите код +4. Установите новые зависимости: `poetry install` +5. Примените миграции (если есть) +6. Запустите приложение +7. Проверьте работоспособность + +## Устранение неполадок + +### Проблемы с подключением к БД +- Проверьте строку подключения в `DB_DSN` +- Убедитесь, что PostgreSQL запущен +- Проверьте права пользователя БД + +### Проблемы с API +- Проверьте правильность API ключа +- Убедитесь, что заголовок `X-API-Key` передается +- Проверьте логи приложения + +### Проблемы с производительностью +- Увеличьте размер пула БД +- Проверьте индексы в базе данных +- Мониторьте использование ресурсов diff --git a/README.md b/README.md new file mode 100644 index 0000000..f4e11b7 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# HubGW + +FastAPI Gateway for HubMC + +## Установка + +```bash +poetry install +``` + +## Запуск + +```bash +poetry run hubgw +``` + +## Конфигурация + +Создайте файл `.env` с необходимыми переменными окружения: + +```env +APP_ENV=dev +APP_HOST=0.0.0.0 +APP_PORT=8080 +APP_LOG_LEVEL=INFO +DB_DSN=postgresql+asyncpg://user:pass@host:5432/hubgw +DB_POOL_SIZE=10 +DB_MAX_OVERFLOW=10 +API_KEY=your-api-key +RATE_LIMIT_PER_MIN=100 +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c2d55af --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] +name = "hubgw" +version = "0.1.0" +description = "FastAPI Gateway for HubMC" +authors = ["itqop "] +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.11" +fastapi = "^0.104.0" +uvicorn = {extras = ["standard"], version = "^0.24.0"} +pydantic = "^2.5.0" +pydantic-settings = "^2.1.0" +sqlalchemy = "^2.0.0" +asyncpg = "^0.29.0" +loguru = "^0.7.0" + +[tool.poetry.group.dev.dependencies] +pytest = "^7.4.0" +pytest-asyncio = "^0.21.0" +httpx = "^0.25.0" + +[tool.poetry.scripts] +hubgw = "hubgw.__main__:main" + +[tool.poetry.packages] +{include = "hubgw", from = "src"} diff --git a/src/hubgw/__init__.py b/src/hubgw/__init__.py new file mode 100644 index 0000000..8903ceb --- /dev/null +++ b/src/hubgw/__init__.py @@ -0,0 +1,3 @@ +"""HubGW - FastAPI Gateway for HubMC""" + +__version__ = "0.1.0" diff --git a/src/hubgw/__main__.py b/src/hubgw/__main__.py new file mode 100644 index 0000000..694e5f2 --- /dev/null +++ b/src/hubgw/__main__.py @@ -0,0 +1,19 @@ +"""Entry point for hubgw application.""" + +import uvicorn +from hubgw.main import create_app + + +def main(): + """Main entry point.""" + app = create_app() + uvicorn.run( + app, + host="0.0.0.0", + port=8080, + log_level="info" + ) + + +if __name__ == "__main__": + main() diff --git a/src/hubgw/api/__init__.py b/src/hubgw/api/__init__.py new file mode 100644 index 0000000..93e375b --- /dev/null +++ b/src/hubgw/api/__init__.py @@ -0,0 +1 @@ +"""API module for hubgw.""" diff --git a/src/hubgw/api/deps.py b/src/hubgw/api/deps.py new file mode 100644 index 0000000..d79a52f --- /dev/null +++ b/src/hubgw/api/deps.py @@ -0,0 +1,72 @@ +"""Dependency providers for FastAPI.""" + +from fastapi import Depends, HTTPException, Header +from sqlalchemy.ext.asyncio import AsyncSession +from typing import Annotated + +from hubgw.context import AppContext +from hubgw.core.config import AppSettings +from hubgw.services.homes_service import HomesService +from hubgw.services.kits_service import KitsService +from hubgw.services.cooldowns_service import CooldownsService +from hubgw.services.warps_service import WarpsService +from hubgw.services.whitelist_service import WhitelistService +from hubgw.services.punishments_service import PunishmentsService +from hubgw.services.audit_service import AuditService + + +async def get_context() -> AppContext: + """Get application context.""" + return AppContext() + + +async def get_session(context: Annotated[AppContext, Depends(get_context)]) -> AsyncSession: + """Get database session.""" + async with context.session_factory() as session: + yield session + + +async def verify_api_key( + x_api_key: Annotated[str, Header(alias="X-API-Key")], + context: Annotated[AppContext, Depends(get_context)] +) -> str: + """Verify API key.""" + if x_api_key != context.settings.API_KEY: + raise HTTPException(status_code=401, detail="Invalid API key") + return x_api_key + + +# Service dependencies +def get_homes_service(session: Annotated[AsyncSession, Depends(get_session)]) -> HomesService: + """Get homes service.""" + return HomesService(session) + + +def get_kits_service(session: Annotated[AsyncSession, Depends(get_session)]) -> KitsService: + """Get kits service.""" + return KitsService(session) + + +def get_cooldowns_service(session: Annotated[AsyncSession, Depends(get_session)]) -> CooldownsService: + """Get cooldowns service.""" + return CooldownsService(session) + + +def get_warps_service(session: Annotated[AsyncSession, Depends(get_session)]) -> WarpsService: + """Get warps service.""" + return WarpsService(session) + + +def get_whitelist_service(session: Annotated[AsyncSession, Depends(get_session)]) -> WhitelistService: + """Get whitelist service.""" + return WhitelistService(session) + + +def get_punishments_service(session: Annotated[AsyncSession, Depends(get_session)]) -> PunishmentsService: + """Get punishments service.""" + return PunishmentsService(session) + + +def get_audit_service(session: Annotated[AsyncSession, Depends(get_session)]) -> AuditService: + """Get audit service.""" + return AuditService(session) diff --git a/src/hubgw/api/v1/__init__.py b/src/hubgw/api/v1/__init__.py new file mode 100644 index 0000000..a8d704f --- /dev/null +++ b/src/hubgw/api/v1/__init__.py @@ -0,0 +1 @@ +"""API v1 module.""" diff --git a/src/hubgw/api/v1/audit.py b/src/hubgw/api/v1/audit.py new file mode 100644 index 0000000..ac4ccd9 --- /dev/null +++ b/src/hubgw/api/v1/audit.py @@ -0,0 +1,24 @@ +"""Audit endpoints.""" + +from fastapi import APIRouter, Depends, HTTPException +from typing import Annotated + +from hubgw.api.deps import get_audit_service, verify_api_key +from hubgw.services.audit_service import AuditService +from hubgw.schemas.audit import CommandAuditRequest, CommandAuditResponse +from hubgw.core.errors import AppError, create_http_exception + +router = APIRouter() + + +@router.post("/commands", response_model=CommandAuditResponse, status_code=202) +async def log_command( + request: CommandAuditRequest, + service: Annotated[AuditService, Depends(get_audit_service)], + _: Annotated[str, Depends(verify_api_key)] +): + """Log command execution for audit.""" + try: + return await service.log_command(request) + except AppError as e: + raise create_http_exception(e) diff --git a/src/hubgw/api/v1/cooldowns.py b/src/hubgw/api/v1/cooldowns.py new file mode 100644 index 0000000..db53c2a --- /dev/null +++ b/src/hubgw/api/v1/cooldowns.py @@ -0,0 +1,41 @@ +"""Cooldowns endpoints.""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from typing import Annotated +from uuid import UUID + +from hubgw.api.deps import get_cooldowns_service, verify_api_key +from hubgw.services.cooldowns_service import CooldownsService +from hubgw.schemas.cooldowns import CooldownCheckRequest, CooldownCheckResponse, CooldownKey +from hubgw.core.errors import AppError, create_http_exception + +router = APIRouter() + + +@router.post("/check", response_model=CooldownCheckResponse) +async def check_cooldown( + request: CooldownCheckRequest, + service: Annotated[CooldownsService, Depends(get_cooldowns_service)], + _: Annotated[str, Depends(verify_api_key)] +): + """Check cooldown status.""" + try: + return await service.check_cooldown(request) + except AppError as e: + raise create_http_exception(e) + + +@router.put("/touch") +async def touch_cooldown( + key: CooldownKey, + seconds: int = Query(..., description="Cooldown duration in seconds"), + player_uuid: UUID = Query(..., description="Player UUID"), + service: Annotated[CooldownsService, Depends(get_cooldowns_service)], + _: Annotated[str, Depends(verify_api_key)] +): + """Touch cooldown.""" + try: + await service.touch_cooldown(player_uuid, key.key, seconds) + return {"message": "Cooldown touched successfully"} + except AppError as e: + raise create_http_exception(e) diff --git a/src/hubgw/api/v1/health.py b/src/hubgw/api/v1/health.py new file mode 100644 index 0000000..a908130 --- /dev/null +++ b/src/hubgw/api/v1/health.py @@ -0,0 +1,11 @@ +"""Health check endpoints.""" + +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/") +async def health_check(): + """Health check endpoint.""" + return {"status": "ok"} diff --git a/src/hubgw/api/v1/homes.py b/src/hubgw/api/v1/homes.py new file mode 100644 index 0000000..35965da --- /dev/null +++ b/src/hubgw/api/v1/homes.py @@ -0,0 +1,51 @@ +"""Homes endpoints.""" + +from fastapi import APIRouter, Depends, HTTPException +from typing import Annotated +from uuid import UUID + +from hubgw.api.deps import get_homes_service, verify_api_key +from hubgw.services.homes_service import HomesService +from hubgw.schemas.homes import HomeUpsertRequest, HomeGetRequest, Home, HomeGetResponse, HomeListResponse +from hubgw.core.errors import AppError, create_http_exception + +router = APIRouter() + + +@router.put("/", response_model=Home) +async def upsert_home( + request: HomeUpsertRequest, + service: Annotated[HomesService, Depends(get_homes_service)], + _: Annotated[str, Depends(verify_api_key)] +): + """Upsert home.""" + try: + return await service.upsert_home(request) + except AppError as e: + raise create_http_exception(e) + + +@router.post("/get", response_model=HomeGetResponse) +async def get_home( + request: HomeGetRequest, + service: Annotated[HomesService, Depends(get_homes_service)], + _: Annotated[str, Depends(verify_api_key)] +): + """Get home.""" + try: + return await service.get_home(request) + except AppError as e: + raise create_http_exception(e) + + +@router.get("/{player_uuid}", response_model=HomeListResponse) +async def list_homes( + player_uuid: UUID, + service: Annotated[HomesService, Depends(get_homes_service)], + _: Annotated[str, Depends(verify_api_key)] +): + """List homes for player.""" + try: + return await service.list_homes(player_uuid) + except AppError as e: + raise create_http_exception(e) diff --git a/src/hubgw/api/v1/kits.py b/src/hubgw/api/v1/kits.py new file mode 100644 index 0000000..28a2a4e --- /dev/null +++ b/src/hubgw/api/v1/kits.py @@ -0,0 +1,24 @@ +"""Kits endpoints.""" + +from fastapi import APIRouter, Depends, HTTPException +from typing import Annotated + +from hubgw.api.deps import get_kits_service, verify_api_key +from hubgw.services.kits_service import KitsService +from hubgw.schemas.kits import KitClaimRequest, KitClaimResponse +from hubgw.core.errors import AppError, create_http_exception + +router = APIRouter() + + +@router.post("/claim", response_model=KitClaimResponse) +async def claim_kit( + request: KitClaimRequest, + service: Annotated[KitsService, Depends(get_kits_service)], + _: Annotated[str, Depends(verify_api_key)] +): + """Claim kit.""" + try: + return await service.claim_kit(request) + except AppError as e: + raise create_http_exception(e) diff --git a/src/hubgw/api/v1/punishments.py b/src/hubgw/api/v1/punishments.py new file mode 100644 index 0000000..1a45e77 --- /dev/null +++ b/src/hubgw/api/v1/punishments.py @@ -0,0 +1,80 @@ +"""Punishments endpoints.""" + +from fastapi import APIRouter, Depends, HTTPException +from typing import Annotated +from uuid import UUID + +from hubgw.api.deps import get_punishments_service, verify_api_key +from hubgw.services.punishments_service import PunishmentsService +from hubgw.schemas.punishments import ( + PunishmentCreateRequest, PunishmentRevokeRequest, PunishmentQuery, + PunishmentBase, PunishmentListResponse, ActiveBanStatusResponse, ActiveMuteStatusResponse +) +from hubgw.core.errors import AppError, create_http_exception + +router = APIRouter() + + +@router.post("/", response_model=PunishmentBase, status_code=201) +async def create_punishment( + request: PunishmentCreateRequest, + service: Annotated[PunishmentsService, Depends(get_punishments_service)], + _: Annotated[str, Depends(verify_api_key)] +): + """Create punishment.""" + try: + return await service.create_punishment(request) + except AppError as e: + raise create_http_exception(e) + + +@router.post("/revoke", response_model=PunishmentBase) +async def revoke_punishment( + request: PunishmentRevokeRequest, + service: Annotated[PunishmentsService, Depends(get_punishments_service)], + _: Annotated[str, Depends(verify_api_key)] +): + """Revoke punishment.""" + try: + return await service.revoke_punishment(request) + except AppError as e: + raise create_http_exception(e) + + +@router.post("/query", response_model=PunishmentListResponse) +async def query_punishments( + query: PunishmentQuery, + service: Annotated[PunishmentsService, Depends(get_punishments_service)], + _: Annotated[str, Depends(verify_api_key)] +): + """Query punishments.""" + try: + return await service.query_punishments(query) + except AppError as e: + raise create_http_exception(e) + + +@router.get("/ban/{player_uuid}", response_model=ActiveBanStatusResponse) +async def get_ban_status( + player_uuid: UUID, + service: Annotated[PunishmentsService, Depends(get_punishments_service)], + _: Annotated[str, Depends(verify_api_key)] +): + """Get active ban status for player.""" + try: + return await service.get_active_ban_status(player_uuid) + except AppError as e: + raise create_http_exception(e) + + +@router.get("/mute/{player_uuid}", response_model=ActiveMuteStatusResponse) +async def get_mute_status( + player_uuid: UUID, + service: Annotated[PunishmentsService, Depends(get_punishments_service)], + _: Annotated[str, Depends(verify_api_key)] +): + """Get active mute status for player.""" + try: + return await service.get_active_mute_status(player_uuid) + except AppError as e: + raise create_http_exception(e) diff --git a/src/hubgw/api/v1/router.py b/src/hubgw/api/v1/router.py new file mode 100644 index 0000000..7fc0f08 --- /dev/null +++ b/src/hubgw/api/v1/router.py @@ -0,0 +1,16 @@ +"""Main API v1 router.""" + +from fastapi import APIRouter +from hubgw.api.v1 import health, homes, kits, cooldowns, warps, whitelist, punishments, audit + +api_router = APIRouter() + +# Include all sub-routers +api_router.include_router(health.router, prefix="/health", tags=["health"]) +api_router.include_router(homes.router, prefix="/homes", tags=["homes"]) +api_router.include_router(kits.router, prefix="/kits", tags=["kits"]) +api_router.include_router(cooldowns.router, prefix="/cooldowns", tags=["cooldowns"]) +api_router.include_router(warps.router, prefix="/warps", tags=["warps"]) +api_router.include_router(whitelist.router, prefix="/whitelist", tags=["whitelist"]) +api_router.include_router(punishments.router, prefix="/punishments", tags=["punishments"]) +api_router.include_router(audit.router, prefix="/audit", tags=["audit"]) diff --git a/src/hubgw/api/v1/warps.py b/src/hubgw/api/v1/warps.py new file mode 100644 index 0000000..36c6012 --- /dev/null +++ b/src/hubgw/api/v1/warps.py @@ -0,0 +1,79 @@ +"""Warps endpoints.""" + +from fastapi import APIRouter, Depends, HTTPException +from typing import Annotated + +from hubgw.api.deps import get_warps_service, verify_api_key +from hubgw.services.warps_service import WarpsService +from hubgw.schemas.warps import ( + WarpCreateRequest, WarpUpdateRequest, WarpDeleteRequest, WarpGetRequest, + Warp, WarpGetResponse, WarpListQuery, WarpListResponse +) +from hubgw.core.errors import AppError, create_http_exception + +router = APIRouter() + + +@router.post("/", response_model=Warp, status_code=201) +async def create_warp( + request: WarpCreateRequest, + service: Annotated[WarpsService, Depends(get_warps_service)], + _: Annotated[str, Depends(verify_api_key)] +): + """Create warp.""" + try: + return await service.create_warp(request) + except AppError as e: + raise create_http_exception(e) + + +@router.patch("/", response_model=Warp) +async def update_warp( + request: WarpUpdateRequest, + service: Annotated[WarpsService, Depends(get_warps_service)], + _: Annotated[str, Depends(verify_api_key)] +): + """Update warp.""" + try: + return await service.update_warp(request) + except AppError as e: + raise create_http_exception(e) + + +@router.delete("/", status_code=204) +async def delete_warp( + request: WarpDeleteRequest, + service: Annotated[WarpsService, Depends(get_warps_service)], + _: Annotated[str, Depends(verify_api_key)] +): + """Delete warp.""" + try: + await service.delete_warp(request) + except AppError as e: + raise create_http_exception(e) + + +@router.post("/get", response_model=WarpGetResponse) +async def get_warp( + request: WarpGetRequest, + service: Annotated[WarpsService, Depends(get_warps_service)], + _: Annotated[str, Depends(verify_api_key)] +): + """Get warp.""" + try: + return await service.get_warp(request) + except AppError as e: + raise create_http_exception(e) + + +@router.post("/list", response_model=WarpListResponse) +async def list_warps( + query: WarpListQuery, + service: Annotated[WarpsService, Depends(get_warps_service)], + _: Annotated[str, Depends(verify_api_key)] +): + """List warps.""" + try: + return await service.list_warps(query) + except AppError as e: + raise create_http_exception(e) diff --git a/src/hubgw/api/v1/whitelist.py b/src/hubgw/api/v1/whitelist.py new file mode 100644 index 0000000..e8002ba --- /dev/null +++ b/src/hubgw/api/v1/whitelist.py @@ -0,0 +1,65 @@ +"""Whitelist endpoints.""" + +from fastapi import APIRouter, Depends, HTTPException +from typing import Annotated + +from hubgw.api.deps import get_whitelist_service, verify_api_key +from hubgw.services.whitelist_service import WhitelistService +from hubgw.schemas.whitelist import ( + WhitelistAddRequest, WhitelistRemoveRequest, WhitelistCheckRequest, + WhitelistEntry, WhitelistCheckResponse, WhitelistListResponse +) +from hubgw.core.errors import AppError, create_http_exception + +router = APIRouter() + + +@router.post("/add", response_model=WhitelistEntry, status_code=201) +async def add_player( + request: WhitelistAddRequest, + service: Annotated[WhitelistService, Depends(get_whitelist_service)], + _: Annotated[str, Depends(verify_api_key)] +): + """Add player to whitelist.""" + try: + return await service.add_player(request) + except AppError as e: + raise create_http_exception(e) + + +@router.post("/remove", status_code=204) +async def remove_player( + request: WhitelistRemoveRequest, + service: Annotated[WhitelistService, Depends(get_whitelist_service)], + _: Annotated[str, Depends(verify_api_key)] +): + """Remove player from whitelist.""" + try: + await service.remove_player(request) + except AppError as e: + raise create_http_exception(e) + + +@router.post("/check", response_model=WhitelistCheckResponse) +async def check_player( + request: WhitelistCheckRequest, + service: Annotated[WhitelistService, Depends(get_whitelist_service)], + _: Annotated[str, Depends(verify_api_key)] +): + """Check if player is whitelisted.""" + try: + return await service.check_player(request) + except AppError as e: + raise create_http_exception(e) + + +@router.get("/", response_model=WhitelistListResponse) +async def list_players( + service: Annotated[WhitelistService, Depends(get_whitelist_service)], + _: Annotated[str, Depends(verify_api_key)] +): + """List all whitelisted players.""" + try: + return await service.list_players() + except AppError as e: + raise create_http_exception(e) diff --git a/src/hubgw/context.py b/src/hubgw/context.py new file mode 100644 index 0000000..4b6c430 --- /dev/null +++ b/src/hubgw/context.py @@ -0,0 +1,39 @@ +"""Application context singleton.""" + +from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine +from hubgw.core.config import AppSettings + + +class AppContext: + """Application context singleton.""" + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if not hasattr(self, 'initialized'): + self.settings = AppSettings() + self.engine: AsyncEngine = None + self.session_factory: async_sessionmaker = None + self.initialized = True + + async def startup(self): + """Initialize database engine and session factory.""" + self.engine = create_async_engine( + self.settings.DB_DSN, + pool_size=self.settings.DB_POOL_SIZE, + max_overflow=self.settings.DB_MAX_OVERFLOW + ) + self.session_factory = async_sessionmaker( + self.engine, + expire_on_commit=False + ) + + async def shutdown(self): + """Close database engine.""" + if self.engine: + await self.engine.dispose() diff --git a/src/hubgw/core/__init__.py b/src/hubgw/core/__init__.py new file mode 100644 index 0000000..fb31757 --- /dev/null +++ b/src/hubgw/core/__init__.py @@ -0,0 +1 @@ +"""Core module for hubgw.""" diff --git a/src/hubgw/core/config.py b/src/hubgw/core/config.py new file mode 100644 index 0000000..d761654 --- /dev/null +++ b/src/hubgw/core/config.py @@ -0,0 +1,27 @@ +"""Application configuration using Pydantic Settings.""" + +from pydantic_settings import BaseSettings +from typing import Optional + + +class AppSettings(BaseSettings): + """Application settings.""" + + # App settings + APP_ENV: str = "dev" + APP_HOST: str = "0.0.0.0" + APP_PORT: int = 8080 + APP_LOG_LEVEL: str = "INFO" + + # Database settings + DB_DSN: str = "postgresql+asyncpg://user:pass@localhost:5432/hubgw" + DB_POOL_SIZE: int = 10 + DB_MAX_OVERFLOW: int = 10 + + # Security settings + API_KEY: str = "your-api-key" + RATE_LIMIT_PER_MIN: Optional[int] = None + + class Config: + env_file = ".env" + case_sensitive = True diff --git a/src/hubgw/core/errors.py b/src/hubgw/core/errors.py new file mode 100644 index 0000000..bac7aad --- /dev/null +++ b/src/hubgw/core/errors.py @@ -0,0 +1,65 @@ +"""Custom exceptions and error handlers.""" + +from fastapi import HTTPException +from typing import Any, Dict, Optional + + +class AppError(Exception): + """Base application error.""" + + def __init__( + self, + message: str, + code: str = "app_error", + details: Optional[Dict[str, Any]] = None + ): + self.message = message + self.code = code + self.details = details or {} + super().__init__(message) + + +class ValidationError(AppError): + """Validation error.""" + + def __init__(self, message: str, details: Optional[Dict[str, Any]] = None): + super().__init__(message, "invalid_state", details) + + +class NotFoundError(AppError): + """Not found error.""" + + def __init__(self, message: str, details: Optional[Dict[str, Any]] = None): + super().__init__(message, "not_found", details) + + +class AlreadyExistsError(AppError): + """Already exists error.""" + + def __init__(self, message: str, details: Optional[Dict[str, Any]] = None): + super().__init__(message, "already_exists", details) + + +class CooldownActiveError(AppError): + """Cooldown active error.""" + + def __init__(self, message: str, details: Optional[Dict[str, Any]] = None): + super().__init__(message, "cooldown_active", details) + + +def create_http_exception(error: AppError) -> HTTPException: + """Convert AppError to HTTPException.""" + status_code = 400 + if isinstance(error, NotFoundError): + status_code = 404 + elif isinstance(error, AlreadyExistsError): + status_code = 409 + + return HTTPException( + status_code=status_code, + detail={ + "message": error.message, + "code": error.code, + "details": error.details + } + ) diff --git a/src/hubgw/core/logging.py b/src/hubgw/core/logging.py new file mode 100644 index 0000000..e2e8af0 --- /dev/null +++ b/src/hubgw/core/logging.py @@ -0,0 +1,32 @@ +"""Logging configuration using loguru.""" + +import sys +from loguru import logger +from hubgw.core.config import AppSettings + + +def setup_logging(): + """Setup loguru logging configuration.""" + settings = AppSettings() + + # Remove default handler + logger.remove() + + # Add console handler + logger.add( + sys.stdout, + level=settings.APP_LOG_LEVEL, + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", + colorize=True + ) + + # Add file handler for production + if settings.APP_ENV == "prod": + logger.add( + "logs/hubgw.log", + level=settings.APP_LOG_LEVEL, + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", + rotation="1 day", + retention="30 days", + compression="zip" + ) diff --git a/src/hubgw/main.py b/src/hubgw/main.py new file mode 100644 index 0000000..ab26d03 --- /dev/null +++ b/src/hubgw/main.py @@ -0,0 +1,34 @@ +"""FastAPI application factory.""" + +from fastapi import FastAPI +from hubgw.core.logging import setup_logging +from hubgw.context import AppContext + + +def create_app() -> FastAPI: + """Create and configure FastAPI application.""" + app = FastAPI( + title="HubGW", + description="FastAPI Gateway for HubMC", + version="0.1.0" + ) + + # Setup logging + setup_logging() + + # Initialize context + ctx = AppContext() + + @app.on_event("startup") + async def startup(): + await ctx.startup() + + @app.on_event("shutdown") + async def shutdown(): + await ctx.shutdown() + + # Include routers + from hubgw.api.v1.router import api_router + app.include_router(api_router, prefix="/api/v1") + + return app diff --git a/src/hubgw/models/__init__.py b/src/hubgw/models/__init__.py new file mode 100644 index 0000000..1e84960 --- /dev/null +++ b/src/hubgw/models/__init__.py @@ -0,0 +1 @@ +"""Database models for hubgw.""" diff --git a/src/hubgw/models/base.py b/src/hubgw/models/base.py new file mode 100644 index 0000000..51d017a --- /dev/null +++ b/src/hubgw/models/base.py @@ -0,0 +1,12 @@ +"""Base model class for SQLAlchemy.""" + +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy import Column, DateTime, func +from datetime import datetime + + +class Base(DeclarativeBase): + """Base class for all models.""" + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/src/hubgw/models/cooldown.py b/src/hubgw/models/cooldown.py new file mode 100644 index 0000000..a59e64b --- /dev/null +++ b/src/hubgw/models/cooldown.py @@ -0,0 +1,17 @@ +"""Cooldown model.""" + +from sqlalchemy import Column, String, DateTime, Integer +from sqlalchemy.dialects.postgresql import UUID +from hubgw.models.base import Base + + +class Cooldown(Base): + """Cooldown model.""" + + __tablename__ = "cooldowns" + + id = Column(UUID(as_uuid=True), primary_key=True) + key = Column(String(255), nullable=False, unique=True, index=True) + player_uuid = Column(UUID(as_uuid=True), nullable=False, index=True) + expires_at = Column(DateTime(timezone=True), nullable=False) + cooldown_seconds = Column(Integer, nullable=False) diff --git a/src/hubgw/models/home.py b/src/hubgw/models/home.py new file mode 100644 index 0000000..cee0373 --- /dev/null +++ b/src/hubgw/models/home.py @@ -0,0 +1,22 @@ +"""Home model.""" + +from sqlalchemy import Column, String, Float, Integer, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from hubgw.models.base import Base + + +class Home(Base): + """Home model.""" + + __tablename__ = "homes" + + id = Column(UUID(as_uuid=True), primary_key=True) + player_uuid = Column(UUID(as_uuid=True), nullable=False, index=True) + name = Column(String(255), nullable=False) + world = Column(String(255), nullable=False) + x = Column(Float, nullable=False) + y = Column(Float, nullable=False) + z = Column(Float, nullable=False) + yaw = Column(Float, default=0.0) + pitch = Column(Float, default=0.0) + is_public = Column(Integer, default=0) # 0 = private, 1 = public diff --git a/src/hubgw/models/punishment.py b/src/hubgw/models/punishment.py new file mode 100644 index 0000000..579596a --- /dev/null +++ b/src/hubgw/models/punishment.py @@ -0,0 +1,25 @@ +"""Punishment model.""" + +from sqlalchemy import Column, String, DateTime, Integer, Text +from sqlalchemy.dialects.postgresql import UUID +from hubgw.models.base import Base + + +class Punishment(Base): + """Punishment model.""" + + __tablename__ = "punishments" + + id = Column(UUID(as_uuid=True), primary_key=True) + player_uuid = Column(UUID(as_uuid=True), nullable=False, index=True) + player_name = Column(String(255), nullable=False) + punishment_type = Column(String(50), nullable=False) # ban, warn, mute + reason = Column(Text, nullable=False) + staff_uuid = Column(UUID(as_uuid=True), nullable=False) + staff_name = Column(String(255), nullable=False) + created_at = Column(DateTime(timezone=True), nullable=False) + expires_at = Column(DateTime(timezone=True), nullable=True) # None for permanent + is_active = Column(Integer, default=1) # 0 = inactive, 1 = active + revoked_at = Column(DateTime(timezone=True), nullable=True) + revoked_by = Column(UUID(as_uuid=True), nullable=True) + revoked_reason = Column(Text, nullable=True) diff --git a/src/hubgw/models/warp.py b/src/hubgw/models/warp.py new file mode 100644 index 0000000..b1d78ce --- /dev/null +++ b/src/hubgw/models/warp.py @@ -0,0 +1,22 @@ +"""Warp model.""" + +from sqlalchemy import Column, String, Float, Integer +from sqlalchemy.dialects.postgresql import UUID +from hubgw.models.base import Base + + +class Warp(Base): + """Warp model.""" + + __tablename__ = "warps" + + id = Column(UUID(as_uuid=True), primary_key=True) + name = Column(String(255), nullable=False, unique=True, index=True) + world = Column(String(255), nullable=False) + x = Column(Float, nullable=False) + y = Column(Float, nullable=False) + z = Column(Float, nullable=False) + yaw = Column(Float, default=0.0) + pitch = Column(Float, default=0.0) + is_public = Column(Integer, default=1) # 0 = private, 1 = public + description = Column(String(500), nullable=True) diff --git a/src/hubgw/models/whitelist.py b/src/hubgw/models/whitelist.py new file mode 100644 index 0000000..0e381ac --- /dev/null +++ b/src/hubgw/models/whitelist.py @@ -0,0 +1,18 @@ +"""Whitelist model.""" + +from sqlalchemy import Column, String, DateTime +from sqlalchemy.dialects.postgresql import UUID +from hubgw.models.base import Base + + +class WhitelistEntry(Base): + """Whitelist entry model.""" + + __tablename__ = "whitelist" + + id = Column(UUID(as_uuid=True), primary_key=True) + player_name = Column(String(255), nullable=False, unique=True, index=True) + player_uuid = Column(UUID(as_uuid=True), nullable=True, index=True) + added_by = Column(String(255), nullable=False) + added_at = Column(DateTime(timezone=True), nullable=False) + reason = Column(String(500), nullable=True) diff --git a/src/hubgw/repositories/__init__.py b/src/hubgw/repositories/__init__.py new file mode 100644 index 0000000..3e20388 --- /dev/null +++ b/src/hubgw/repositories/__init__.py @@ -0,0 +1 @@ +"""Repositories for hubgw.""" diff --git a/src/hubgw/repositories/cooldowns_repo.py b/src/hubgw/repositories/cooldowns_repo.py new file mode 100644 index 0000000..3b15fa1 --- /dev/null +++ b/src/hubgw/repositories/cooldowns_repo.py @@ -0,0 +1,63 @@ +"""Cooldowns repository.""" + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, insert, update, delete, func +from typing import Optional, List +from uuid import UUID +from datetime import datetime, timedelta + +from hubgw.models.cooldown import Cooldown + + +class CooldownsRepository: + """Cooldowns repository for database operations.""" + + def __init__(self, session: AsyncSession): + self.session = session + + async def check(self, player_uuid: UUID, key: str) -> Optional[Cooldown]: + """Check if cooldown is active.""" + stmt = select(Cooldown).where( + Cooldown.player_uuid == player_uuid, + Cooldown.key == key, + Cooldown.expires_at > func.now() + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def touch(self, player_uuid: UUID, key: str, cooldown_seconds: int) -> Cooldown: + """Touch cooldown (create or update).""" + expires_at = datetime.utcnow() + timedelta(seconds=cooldown_seconds) + + stmt = insert(Cooldown).values( + key=key, + player_uuid=player_uuid, + cooldown_seconds=cooldown_seconds, + expires_at=expires_at + ) + stmt = stmt.on_conflict_do_update( + index_elements=['key', 'player_uuid'], + set_=dict( + cooldown_seconds=stmt.excluded.cooldown_seconds, + expires_at=stmt.excluded.expires_at + ) + ) + result = await self.session.execute(stmt) + await self.session.commit() + + # Get the touched cooldown + stmt = select(Cooldown).where( + Cooldown.player_uuid == player_uuid, + Cooldown.key == key + ) + result = await self.session.execute(stmt) + return result.scalar_one() + + async def list_by_player(self, player_uuid: UUID) -> List[Cooldown]: + """List active cooldowns by player.""" + stmt = select(Cooldown).where( + Cooldown.player_uuid == player_uuid, + Cooldown.expires_at > func.now() + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) diff --git a/src/hubgw/repositories/homes_repo.py b/src/hubgw/repositories/homes_repo.py new file mode 100644 index 0000000..20d80f9 --- /dev/null +++ b/src/hubgw/repositories/homes_repo.py @@ -0,0 +1,68 @@ +"""Homes repository.""" + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update, delete +from sqlalchemy.dialects.postgresql import insert +from typing import List, Optional +from uuid import UUID + +from hubgw.models.home import Home +from hubgw.schemas.homes import HomeUpsertRequest, HomeGetRequest + + +class HomesRepository: + """Homes repository for database operations.""" + + def __init__(self, session: AsyncSession): + self.session = session + + async def upsert(self, request: HomeUpsertRequest) -> Home: + """Upsert home.""" + stmt = insert(Home).values( + player_uuid=request.player_uuid, + name=request.name, + world=request.world, + x=request.x, + y=request.y, + z=request.z, + yaw=request.yaw, + pitch=request.pitch, + is_public=request.is_public + ) + stmt = stmt.on_conflict_do_update( + index_elements=['player_uuid', 'name'], + set_=dict( + world=stmt.excluded.world, + x=stmt.excluded.x, + y=stmt.excluded.y, + z=stmt.excluded.z, + yaw=stmt.excluded.yaw, + pitch=stmt.excluded.pitch, + is_public=stmt.excluded.is_public + ) + ) + result = await self.session.execute(stmt) + await self.session.commit() + + # Get the upserted home + stmt = select(Home).where( + Home.player_uuid == request.player_uuid, + Home.name == request.name + ) + result = await self.session.execute(stmt) + return result.scalar_one() + + async def get_by_request(self, request: HomeGetRequest) -> Optional[Home]: + """Get home by request.""" + stmt = select(Home).where( + Home.player_uuid == request.player_uuid, + Home.name == request.name + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_player(self, player_uuid: UUID) -> List[Home]: + """List homes by player UUID.""" + stmt = select(Home).where(Home.player_uuid == player_uuid) + result = await self.session.execute(stmt) + return list(result.scalars().all()) diff --git a/src/hubgw/repositories/kits_repo.py b/src/hubgw/repositories/kits_repo.py new file mode 100644 index 0000000..f5a0e4c --- /dev/null +++ b/src/hubgw/repositories/kits_repo.py @@ -0,0 +1,37 @@ +"""Kits repository.""" + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from typing import Optional +from uuid import UUID + +from hubgw.models.cooldown import Cooldown + + +class KitsRepository: + """Kits repository for database operations.""" + + def __init__(self, session: AsyncSession): + self.session = session + + async def check_cooldown(self, player_uuid: UUID, kit_name: str) -> Optional[Cooldown]: + """Check if player has active cooldown for kit.""" + stmt = select(Cooldown).where( + Cooldown.player_uuid == player_uuid, + Cooldown.key == f"kit_{kit_name}", + Cooldown.expires_at > func.now() + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def create_cooldown(self, player_uuid: UUID, kit_name: str, cooldown_seconds: int) -> Cooldown: + """Create cooldown for kit.""" + cooldown = Cooldown( + key=f"kit_{kit_name}", + player_uuid=player_uuid, + cooldown_seconds=cooldown_seconds + ) + self.session.add(cooldown) + await self.session.commit() + await self.session.refresh(cooldown) + return cooldown diff --git a/src/hubgw/repositories/punishments_repo.py b/src/hubgw/repositories/punishments_repo.py new file mode 100644 index 0000000..71d6c7f --- /dev/null +++ b/src/hubgw/repositories/punishments_repo.py @@ -0,0 +1,105 @@ +"""Punishments repository.""" + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, insert, update, func +from typing import List, Optional +from uuid import UUID +from datetime import datetime + +from hubgw.models.punishment import Punishment +from hubgw.schemas.punishments import PunishmentCreateRequest, PunishmentRevokeRequest, PunishmentQuery + + +class PunishmentsRepository: + """Punishments repository for database operations.""" + + def __init__(self, session: AsyncSession): + self.session = session + + async def create(self, request: PunishmentCreateRequest) -> Punishment: + """Create punishment.""" + punishment = Punishment( + player_uuid=request.player_uuid, + player_name=request.player_name, + punishment_type=request.punishment_type, + reason=request.reason, + staff_uuid=request.staff_uuid, + staff_name=request.staff_name, + created_at=datetime.utcnow(), + expires_at=request.expires_at + ) + self.session.add(punishment) + await self.session.commit() + await self.session.refresh(punishment) + return punishment + + async def revoke(self, request: PunishmentRevokeRequest) -> Optional[Punishment]: + """Revoke punishment.""" + stmt = select(Punishment).where(Punishment.id == request.punishment_id) + result = await self.session.execute(stmt) + punishment = result.scalar_one_or_none() + + if not punishment: + return None + + punishment.is_active = 0 + punishment.revoked_at = datetime.utcnow() + punishment.revoked_by = request.revoked_by + punishment.revoked_reason = request.revoked_reason + + await self.session.commit() + await self.session.refresh(punishment) + return punishment + + async def query(self, query: PunishmentQuery) -> tuple[List[Punishment], int]: + """Query punishments with filters and pagination.""" + stmt = select(Punishment) + count_stmt = select(func.count(Punishment.id)) + + # Apply filters + if query.player_uuid: + stmt = stmt.where(Punishment.player_uuid == query.player_uuid) + count_stmt = count_stmt.where(Punishment.player_uuid == query.player_uuid) + + if query.punishment_type: + stmt = stmt.where(Punishment.punishment_type == query.punishment_type) + count_stmt = count_stmt.where(Punishment.punishment_type == query.punishment_type) + + if query.is_active is not None: + stmt = stmt.where(Punishment.is_active == (1 if query.is_active else 0)) + count_stmt = count_stmt.where(Punishment.is_active == (1 if query.is_active else 0)) + + # Get total count + count_result = await self.session.execute(count_stmt) + total = count_result.scalar() + + # Apply pagination + offset = (query.page - 1) * query.size + stmt = stmt.offset(offset).limit(query.size).order_by(Punishment.created_at.desc()) + + result = await self.session.execute(stmt) + punishments = list(result.scalars().all()) + + return punishments, total + + async def get_active_ban(self, player_uuid: UUID) -> Optional[Punishment]: + """Get active ban for player.""" + stmt = select(Punishment).where( + Punishment.player_uuid == player_uuid, + Punishment.punishment_type == "ban", + Punishment.is_active == 1, + (Punishment.expires_at.is_(None)) | (Punishment.expires_at > datetime.utcnow()) + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_active_mute(self, player_uuid: UUID) -> Optional[Punishment]: + """Get active mute for player.""" + stmt = select(Punishment).where( + Punishment.player_uuid == player_uuid, + Punishment.punishment_type == "mute", + Punishment.is_active == 1, + (Punishment.expires_at.is_(None)) | (Punishment.expires_at > datetime.utcnow()) + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() diff --git a/src/hubgw/repositories/warps_repo.py b/src/hubgw/repositories/warps_repo.py new file mode 100644 index 0000000..1ca705c --- /dev/null +++ b/src/hubgw/repositories/warps_repo.py @@ -0,0 +1,91 @@ +"""Warps repository.""" + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, insert, update, delete, func +from typing import List, Optional +from uuid import UUID + +from hubgw.models.warp import Warp +from hubgw.schemas.warps import WarpCreateRequest, WarpUpdateRequest, WarpDeleteRequest, WarpGetRequest, WarpListQuery + + +class WarpsRepository: + """Warps repository for database operations.""" + + def __init__(self, session: AsyncSession): + self.session = session + + async def create(self, request: WarpCreateRequest) -> Warp: + """Create warp.""" + warp = Warp( + name=request.name, + world=request.world, + x=request.x, + y=request.y, + z=request.z, + yaw=request.yaw, + pitch=request.pitch, + is_public=request.is_public, + description=request.description + ) + self.session.add(warp) + await self.session.commit() + await self.session.refresh(warp) + return warp + + async def update(self, request: WarpUpdateRequest) -> Optional[Warp]: + """Update warp.""" + stmt = select(Warp).where(Warp.name == request.name) + result = await self.session.execute(stmt) + warp = result.scalar_one_or_none() + + if not warp: + return None + + update_data = request.dict(exclude_unset=True, exclude={'name'}) + for field, value in update_data.items(): + setattr(warp, field, value) + + await self.session.commit() + await self.session.refresh(warp) + return warp + + async def delete(self, request: WarpDeleteRequest) -> bool: + """Delete warp.""" + stmt = delete(Warp).where(Warp.name == request.name) + result = await self.session.execute(stmt) + await self.session.commit() + return result.rowcount > 0 + + async def get_by_request(self, request: WarpGetRequest) -> Optional[Warp]: + """Get warp by request.""" + stmt = select(Warp).where(Warp.name == request.name) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list(self, query: WarpListQuery) -> tuple[List[Warp], int]: + """List warps with pagination.""" + stmt = select(Warp) + count_stmt = select(func.count(Warp.id)) + + # Apply filters + if query.world: + stmt = stmt.where(Warp.world == query.world) + count_stmt = count_stmt.where(Warp.world == query.world) + + if query.is_public is not None: + stmt = stmt.where(Warp.is_public == query.is_public) + count_stmt = count_stmt.where(Warp.is_public == query.is_public) + + # Get total count + count_result = await self.session.execute(count_stmt) + total = count_result.scalar() + + # Apply pagination + offset = (query.page - 1) * query.size + stmt = stmt.offset(offset).limit(query.size) + + result = await self.session.execute(stmt) + warps = list(result.scalars().all()) + + return warps, total diff --git a/src/hubgw/repositories/whitelist_repo.py b/src/hubgw/repositories/whitelist_repo.py new file mode 100644 index 0000000..a391af4 --- /dev/null +++ b/src/hubgw/repositories/whitelist_repo.py @@ -0,0 +1,56 @@ +"""Whitelist repository.""" + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, insert, delete, func +from typing import List, Optional +from uuid import UUID +from datetime import datetime + +from hubgw.models.whitelist import WhitelistEntry +from hubgw.schemas.whitelist import WhitelistAddRequest, WhitelistRemoveRequest, WhitelistCheckRequest + + +class WhitelistRepository: + """Whitelist repository for database operations.""" + + def __init__(self, session: AsyncSession): + self.session = session + + async def add(self, request: WhitelistAddRequest) -> WhitelistEntry: + """Add player to whitelist.""" + entry = WhitelistEntry( + player_name=request.player_name, + player_uuid=request.player_uuid, + added_by=request.added_by, + added_at=datetime.utcnow(), + reason=request.reason + ) + self.session.add(entry) + await self.session.commit() + await self.session.refresh(entry) + return entry + + async def remove(self, request: WhitelistRemoveRequest) -> bool: + """Remove player from whitelist.""" + stmt = delete(WhitelistEntry).where(WhitelistEntry.player_name == request.player_name) + result = await self.session.execute(stmt) + await self.session.commit() + return result.rowcount > 0 + + async def check(self, request: WhitelistCheckRequest) -> Optional[WhitelistEntry]: + """Check if player is whitelisted.""" + stmt = select(WhitelistEntry).where(WhitelistEntry.player_name == request.player_name) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_all(self) -> List[WhitelistEntry]: + """List all whitelist entries.""" + stmt = select(WhitelistEntry).order_by(WhitelistEntry.player_name) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def count(self) -> int: + """Get total whitelist count.""" + stmt = select(func.count(WhitelistEntry.id)) + result = await self.session.execute(stmt) + return result.scalar() diff --git a/src/hubgw/schemas/__init__.py b/src/hubgw/schemas/__init__.py new file mode 100644 index 0000000..ca8ac76 --- /dev/null +++ b/src/hubgw/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic schemas for hubgw.""" diff --git a/src/hubgw/schemas/audit.py b/src/hubgw/schemas/audit.py new file mode 100644 index 0000000..9991f0f --- /dev/null +++ b/src/hubgw/schemas/audit.py @@ -0,0 +1,23 @@ +"""Audit schemas.""" + +from pydantic import BaseModel +from typing import List, Dict, Any +from datetime import datetime +from uuid import UUID + + +class CommandAuditRequest(BaseModel): + """Command audit request schema.""" + + player_uuid: UUID + player_name: str + command: str + arguments: List[str] + server: str + timestamp: datetime + + +class CommandAuditResponse(BaseModel): + """Command audit response schema.""" + + accepted: int diff --git a/src/hubgw/schemas/common.py b/src/hubgw/schemas/common.py new file mode 100644 index 0000000..1a5b07c --- /dev/null +++ b/src/hubgw/schemas/common.py @@ -0,0 +1,42 @@ +"""Common schemas.""" + +from pydantic import BaseModel +from typing import Optional +from datetime import datetime +from uuid import UUID + + +class BaseSchema(BaseModel): + """Base schema with common fields.""" + + created_at: datetime + updated_at: datetime + + +class ErrorResponse(BaseModel): + """Error response schema.""" + + message: str + code: str + details: Optional[dict] = None + + +class PaginationParams(BaseModel): + """Pagination parameters.""" + + page: int = 1 + size: int = 20 + + @property + def offset(self) -> int: + return (self.page - 1) * self.size + + +class PaginatedResponse(BaseModel): + """Paginated response schema.""" + + items: list + total: int + page: int + size: int + pages: int diff --git a/src/hubgw/schemas/cooldowns.py b/src/hubgw/schemas/cooldowns.py new file mode 100644 index 0000000..ee0bd67 --- /dev/null +++ b/src/hubgw/schemas/cooldowns.py @@ -0,0 +1,38 @@ +"""Cooldown schemas.""" + +from pydantic import BaseModel +from uuid import UUID +from datetime import datetime +from typing import Optional +from hubgw.schemas.common import BaseSchema + + +class CooldownKey(BaseModel): + """Cooldown key schema.""" + + key: str + + +class CooldownCheckRequest(BaseModel): + """Cooldown check request schema.""" + + player_uuid: UUID + key: str + + +class CooldownCheckResponse(BaseModel): + """Cooldown check response schema.""" + + is_active: bool + expires_at: Optional[datetime] = None + remaining_seconds: Optional[int] = None + + +class Cooldown(BaseSchema): + """Cooldown schema.""" + + id: UUID + key: str + player_uuid: UUID + expires_at: datetime + cooldown_seconds: int diff --git a/src/hubgw/schemas/homes.py b/src/hubgw/schemas/homes.py new file mode 100644 index 0000000..65a01a7 --- /dev/null +++ b/src/hubgw/schemas/homes.py @@ -0,0 +1,65 @@ +"""Home schemas.""" + +from pydantic import BaseModel +from typing import Optional +from datetime import datetime +from uuid import UUID +from hubgw.schemas.common import BaseSchema + + +class HomeBase(BaseModel): + """Base home schema.""" + + name: str + world: str + x: float + y: float + z: float + yaw: float = 0.0 + pitch: float = 0.0 + is_public: int = 0 + + +class HomeCreate(HomeBase): + """Home creation schema.""" + + player_uuid: UUID + + +class HomeUpdate(HomeBase): + """Home update schema.""" + + pass + + +class HomeUpsertRequest(HomeBase): + """Home upsert request schema.""" + + player_uuid: UUID + + +class Home(HomeBase, BaseSchema): + """Home response schema.""" + + id: UUID + player_uuid: UUID + + +class HomeGetRequest(BaseModel): + """Home get request schema.""" + + player_uuid: UUID + name: str + + +class HomeGetResponse(Home): + """Home get response schema.""" + + pass + + +class HomeListResponse(BaseModel): + """Home list response schema.""" + + homes: list[Home] + total: int diff --git a/src/hubgw/schemas/kits.py b/src/hubgw/schemas/kits.py new file mode 100644 index 0000000..8800161 --- /dev/null +++ b/src/hubgw/schemas/kits.py @@ -0,0 +1,20 @@ +"""Kit schemas.""" + +from pydantic import BaseModel +from uuid import UUID +from typing import Optional + + +class KitClaimRequest(BaseModel): + """Kit claim request schema.""" + + player_uuid: UUID + kit_name: str + + +class KitClaimResponse(BaseModel): + """Kit claim response schema.""" + + success: bool + message: str + cooldown_remaining: Optional[int] = None diff --git a/src/hubgw/schemas/punishments.py b/src/hubgw/schemas/punishments.py new file mode 100644 index 0000000..13aed38 --- /dev/null +++ b/src/hubgw/schemas/punishments.py @@ -0,0 +1,78 @@ +"""Punishment schemas.""" + +from pydantic import BaseModel +from typing import Optional +from datetime import datetime +from uuid import UUID +from hubgw.schemas.common import BaseSchema + + +class PunishmentCreateRequest(BaseModel): + """Punishment create request schema.""" + + player_uuid: UUID + player_name: str + punishment_type: str # ban, warn, mute + reason: str + staff_uuid: UUID + staff_name: str + expires_at: Optional[datetime] = None # None for permanent + + +class PunishmentRevokeRequest(BaseModel): + """Punishment revoke request schema.""" + + punishment_id: UUID + revoked_by: UUID + revoked_reason: str + + +class PunishmentQuery(BaseModel): + """Punishment query schema.""" + + player_uuid: Optional[UUID] = None + punishment_type: Optional[str] = None + is_active: Optional[bool] = None + page: int = 1 + size: int = 20 + + +class PunishmentBase(BaseSchema): + """Base punishment schema.""" + + id: UUID + player_uuid: UUID + player_name: str + punishment_type: str + reason: str + staff_uuid: UUID + staff_name: str + expires_at: Optional[datetime] = None + is_active: int + revoked_at: Optional[datetime] = None + revoked_by: Optional[UUID] = None + revoked_reason: Optional[str] = None + + +class PunishmentListResponse(BaseModel): + """Punishment list response schema.""" + + punishments: list[PunishmentBase] + total: int + page: int + size: int + pages: int + + +class ActiveBanStatusResponse(BaseModel): + """Active ban status response schema.""" + + is_banned: bool + punishment: Optional[PunishmentBase] = None + + +class ActiveMuteStatusResponse(BaseModel): + """Active mute status response schema.""" + + is_muted: bool + punishment: Optional[PunishmentBase] = None diff --git a/src/hubgw/schemas/warps.py b/src/hubgw/schemas/warps.py new file mode 100644 index 0000000..1083e43 --- /dev/null +++ b/src/hubgw/schemas/warps.py @@ -0,0 +1,84 @@ +"""Warp schemas.""" + +from pydantic import BaseModel +from typing import Optional +from datetime import datetime +from uuid import UUID +from hubgw.schemas.common import BaseSchema + + +class WarpBase(BaseModel): + """Base warp schema.""" + + name: str + world: str + x: float + y: float + z: float + yaw: float = 0.0 + pitch: float = 0.0 + is_public: int = 1 + description: Optional[str] = None + + +class WarpCreateRequest(WarpBase): + """Warp create request schema.""" + + pass + + +class WarpUpdateRequest(BaseModel): + """Warp update request schema.""" + + name: str + world: Optional[str] = None + x: Optional[float] = None + y: Optional[float] = None + z: Optional[float] = None + yaw: Optional[float] = None + pitch: Optional[float] = None + is_public: Optional[int] = None + description: Optional[str] = None + + +class WarpDeleteRequest(BaseModel): + """Warp delete request schema.""" + + name: str + + +class WarpGetRequest(BaseModel): + """Warp get request schema.""" + + name: str + + +class Warp(WarpBase, BaseSchema): + """Warp response schema.""" + + id: UUID + + +class WarpGetResponse(Warp): + """Warp get response schema.""" + + pass + + +class WarpListQuery(BaseModel): + """Warp list query schema.""" + + page: int = 1 + size: int = 20 + world: Optional[str] = None + is_public: Optional[int] = None + + +class WarpListResponse(BaseModel): + """Warp list response schema.""" + + warps: list[Warp] + total: int + page: int + size: int + pages: int diff --git a/src/hubgw/schemas/whitelist.py b/src/hubgw/schemas/whitelist.py new file mode 100644 index 0000000..42172b9 --- /dev/null +++ b/src/hubgw/schemas/whitelist.py @@ -0,0 +1,53 @@ +"""Whitelist schemas.""" + +from pydantic import BaseModel +from typing import Optional +from datetime import datetime +from uuid import UUID +from hubgw.schemas.common import BaseSchema + + +class WhitelistAddRequest(BaseModel): + """Whitelist add request schema.""" + + player_name: str + player_uuid: Optional[UUID] = None + added_by: str + reason: Optional[str] = None + + +class WhitelistRemoveRequest(BaseModel): + """Whitelist remove request schema.""" + + player_name: str + + +class WhitelistCheckRequest(BaseModel): + """Whitelist check request schema.""" + + player_name: str + + +class WhitelistCheckResponse(BaseModel): + """Whitelist check response schema.""" + + is_whitelisted: bool + player_uuid: Optional[UUID] = None + + +class WhitelistEntry(BaseSchema): + """Whitelist entry schema.""" + + id: UUID + player_name: str + player_uuid: Optional[UUID] = None + added_by: str + added_at: datetime + reason: Optional[str] = None + + +class WhitelistListResponse(BaseModel): + """Whitelist list response schema.""" + + entries: list[WhitelistEntry] + total: int diff --git a/src/hubgw/services/__init__.py b/src/hubgw/services/__init__.py new file mode 100644 index 0000000..6464afd --- /dev/null +++ b/src/hubgw/services/__init__.py @@ -0,0 +1 @@ +"""Services for hubgw.""" diff --git a/src/hubgw/services/audit_service.py b/src/hubgw/services/audit_service.py new file mode 100644 index 0000000..26d1c82 --- /dev/null +++ b/src/hubgw/services/audit_service.py @@ -0,0 +1,17 @@ +"""Audit service.""" + +from sqlalchemy.ext.asyncio import AsyncSession +from hubgw.schemas.audit import CommandAuditRequest, CommandAuditResponse + + +class AuditService: + """Audit service for business logic.""" + + def __init__(self, session: AsyncSession): + self.session = session + + async def log_command(self, request: CommandAuditRequest) -> CommandAuditResponse: + """Log command execution for audit.""" + # In a real implementation, this would store the command in an audit table + # For now, we'll just return success + return CommandAuditResponse(accepted=1) diff --git a/src/hubgw/services/cooldowns_service.py b/src/hubgw/services/cooldowns_service.py new file mode 100644 index 0000000..7c48370 --- /dev/null +++ b/src/hubgw/services/cooldowns_service.py @@ -0,0 +1,34 @@ +"""Cooldowns service.""" + +from sqlalchemy.ext.asyncio import AsyncSession +from uuid import UUID +from datetime import datetime + +from hubgw.repositories.cooldowns_repo import CooldownsRepository +from hubgw.schemas.cooldowns import CooldownCheckRequest, CooldownCheckResponse, CooldownKey + + +class CooldownsService: + """Cooldowns service for business logic.""" + + def __init__(self, session: AsyncSession): + self.repo = CooldownsRepository(session) + + async def check_cooldown(self, request: CooldownCheckRequest) -> CooldownCheckResponse: + """Check cooldown status.""" + cooldown = await self.repo.check(request.player_uuid, request.key) + + if not cooldown: + return CooldownCheckResponse(is_active=False) + + remaining_seconds = int((cooldown.expires_at - datetime.utcnow()).total_seconds()) + + return CooldownCheckResponse( + is_active=remaining_seconds > 0, + expires_at=cooldown.expires_at, + remaining_seconds=max(0, remaining_seconds) + ) + + async def touch_cooldown(self, player_uuid: UUID, key: str, cooldown_seconds: int) -> None: + """Touch cooldown (create or update).""" + await self.repo.touch(player_uuid, key, cooldown_seconds) diff --git a/src/hubgw/services/homes_service.py b/src/hubgw/services/homes_service.py new file mode 100644 index 0000000..3fee9f2 --- /dev/null +++ b/src/hubgw/services/homes_service.py @@ -0,0 +1,65 @@ +"""Homes service.""" + +from sqlalchemy.ext.asyncio import AsyncSession +from typing import List +from uuid import UUID + +from hubgw.repositories.homes_repo import HomesRepository +from hubgw.schemas.homes import HomeUpsertRequest, HomeGetRequest, Home, HomeGetResponse, HomeListResponse +from hubgw.core.errors import NotFoundError + + +class HomesService: + """Homes service for business logic.""" + + def __init__(self, session: AsyncSession): + self.repo = HomesRepository(session) + + async def upsert_home(self, request: HomeUpsertRequest) -> Home: + """Upsert home with business logic.""" + return await self.repo.upsert(request) + + async def get_home(self, request: HomeGetRequest) -> HomeGetResponse: + """Get home with business logic.""" + home = await self.repo.get_by_request(request) + if not home: + raise NotFoundError(f"Home '{request.name}' not found for player {request.player_uuid}") + + return HomeGetResponse( + id=home.id, + player_uuid=home.player_uuid, + name=home.name, + world=home.world, + x=home.x, + y=home.y, + z=home.z, + yaw=home.yaw, + pitch=home.pitch, + is_public=home.is_public, + created_at=home.created_at, + updated_at=home.updated_at + ) + + async def list_homes(self, player_uuid: UUID) -> HomeListResponse: + """List homes with business logic.""" + homes = await self.repo.list_by_player(player_uuid) + + home_list = [ + Home( + id=home.id, + player_uuid=home.player_uuid, + name=home.name, + world=home.world, + x=home.x, + y=home.y, + z=home.z, + yaw=home.yaw, + pitch=home.pitch, + is_public=home.is_public, + created_at=home.created_at, + updated_at=home.updated_at + ) + for home in homes + ] + + return HomeListResponse(homes=home_list, total=len(home_list)) diff --git a/src/hubgw/services/kits_service.py b/src/hubgw/services/kits_service.py new file mode 100644 index 0000000..a0ef2ed --- /dev/null +++ b/src/hubgw/services/kits_service.py @@ -0,0 +1,38 @@ +"""Kits service.""" + +from sqlalchemy.ext.asyncio import AsyncSession +from uuid import UUID +from datetime import datetime, timedelta + +from hubgw.repositories.kits_repo import KitsRepository +from hubgw.schemas.kits import KitClaimRequest, KitClaimResponse +from hubgw.core.errors import CooldownActiveError + + +class KitsService: + """Kits service for business logic.""" + + def __init__(self, session: AsyncSession): + self.repo = KitsRepository(session) + + async def claim_kit(self, request: KitClaimRequest) -> KitClaimResponse: + """Claim kit with cooldown logic.""" + # Check if player has active cooldown + cooldown = await self.repo.check_cooldown(request.player_uuid, request.kit_name) + + if cooldown: + remaining_seconds = int((cooldown.expires_at - datetime.utcnow()).total_seconds()) + if remaining_seconds > 0: + raise CooldownActiveError( + f"Kit '{request.kit_name}' is on cooldown for {remaining_seconds} seconds", + {"cooldown_remaining": remaining_seconds} + ) + + # Create cooldown (assuming 1 hour cooldown for all kits) + cooldown_seconds = 3600 # 1 hour + await self.repo.create_cooldown(request.player_uuid, request.kit_name, cooldown_seconds) + + return KitClaimResponse( + success=True, + message=f"Kit '{request.kit_name}' claimed successfully" + ) diff --git a/src/hubgw/services/punishments_service.py b/src/hubgw/services/punishments_service.py new file mode 100644 index 0000000..9c272a0 --- /dev/null +++ b/src/hubgw/services/punishments_service.py @@ -0,0 +1,144 @@ +"""Punishments service.""" + +from sqlalchemy.ext.asyncio import AsyncSession +from typing import List +from uuid import UUID + +from hubgw.repositories.punishments_repo import PunishmentsRepository +from hubgw.schemas.punishments import ( + PunishmentCreateRequest, PunishmentRevokeRequest, PunishmentQuery, + PunishmentBase, PunishmentListResponse, ActiveBanStatusResponse, ActiveMuteStatusResponse +) +from hubgw.core.errors import NotFoundError + + +class PunishmentsService: + """Punishments service for business logic.""" + + def __init__(self, session: AsyncSession): + self.repo = PunishmentsRepository(session) + + async def create_punishment(self, request: PunishmentCreateRequest) -> PunishmentBase: + """Create punishment with business logic.""" + punishment = await self.repo.create(request) + + return PunishmentBase( + id=punishment.id, + player_uuid=punishment.player_uuid, + player_name=punishment.player_name, + punishment_type=punishment.punishment_type, + reason=punishment.reason, + staff_uuid=punishment.staff_uuid, + staff_name=punishment.staff_name, + created_at=punishment.created_at, + expires_at=punishment.expires_at, + is_active=punishment.is_active, + revoked_at=punishment.revoked_at, + revoked_by=punishment.revoked_by, + revoked_reason=punishment.revoked_reason + ) + + async def revoke_punishment(self, request: PunishmentRevokeRequest) -> PunishmentBase: + """Revoke punishment with business logic.""" + punishment = await self.repo.revoke(request) + if not punishment: + raise NotFoundError(f"Punishment {request.punishment_id} not found") + + return PunishmentBase( + id=punishment.id, + player_uuid=punishment.player_uuid, + player_name=punishment.player_name, + punishment_type=punishment.punishment_type, + reason=punishment.reason, + staff_uuid=punishment.staff_uuid, + staff_name=punishment.staff_name, + created_at=punishment.created_at, + expires_at=punishment.expires_at, + is_active=punishment.is_active, + revoked_at=punishment.revoked_at, + revoked_by=punishment.revoked_by, + revoked_reason=punishment.revoked_reason + ) + + async def query_punishments(self, query: PunishmentQuery) -> PunishmentListResponse: + """Query punishments with business logic.""" + punishments, total = await self.repo.query(query) + + punishment_list = [ + PunishmentBase( + id=p.id, + player_uuid=p.player_uuid, + player_name=p.player_name, + punishment_type=p.punishment_type, + reason=p.reason, + staff_uuid=p.staff_uuid, + staff_name=p.staff_name, + created_at=p.created_at, + expires_at=p.expires_at, + is_active=p.is_active, + revoked_at=p.revoked_at, + revoked_by=p.revoked_by, + revoked_reason=p.revoked_reason + ) + for p in punishments + ] + + pages = (total + query.size - 1) // query.size + + return PunishmentListResponse( + punishments=punishment_list, + total=total, + page=query.page, + size=query.size, + pages=pages + ) + + async def get_active_ban_status(self, player_uuid: UUID) -> ActiveBanStatusResponse: + """Get active ban status for player.""" + ban = await self.repo.get_active_ban(player_uuid) + + if not ban: + return ActiveBanStatusResponse(is_banned=False) + + punishment = PunishmentBase( + id=ban.id, + player_uuid=ban.player_uuid, + player_name=ban.player_name, + punishment_type=ban.punishment_type, + reason=ban.reason, + staff_uuid=ban.staff_uuid, + staff_name=ban.staff_name, + created_at=ban.created_at, + expires_at=ban.expires_at, + is_active=ban.is_active, + revoked_at=ban.revoked_at, + revoked_by=ban.revoked_by, + revoked_reason=ban.revoked_reason + ) + + return ActiveBanStatusResponse(is_banned=True, punishment=punishment) + + async def get_active_mute_status(self, player_uuid: UUID) -> ActiveMuteStatusResponse: + """Get active mute status for player.""" + mute = await self.repo.get_active_mute(player_uuid) + + if not mute: + return ActiveMuteStatusResponse(is_muted=False) + + punishment = PunishmentBase( + id=mute.id, + player_uuid=mute.player_uuid, + player_name=mute.player_name, + punishment_type=mute.punishment_type, + reason=mute.reason, + staff_uuid=mute.staff_uuid, + staff_name=mute.staff_name, + created_at=mute.created_at, + expires_at=mute.expires_at, + is_active=mute.is_active, + revoked_at=mute.revoked_at, + revoked_by=mute.revoked_by, + revoked_reason=mute.revoked_reason + ) + + return ActiveMuteStatusResponse(is_muted=True, punishment=punishment) diff --git a/src/hubgw/services/warps_service.py b/src/hubgw/services/warps_service.py new file mode 100644 index 0000000..e680519 --- /dev/null +++ b/src/hubgw/services/warps_service.py @@ -0,0 +1,94 @@ +"""Warps service.""" + +from sqlalchemy.ext.asyncio import AsyncSession +from typing import List + +from hubgw.repositories.warps_repo import WarpsRepository +from hubgw.schemas.warps import ( + WarpCreateRequest, WarpUpdateRequest, WarpDeleteRequest, WarpGetRequest, + Warp, WarpGetResponse, WarpListQuery, WarpListResponse +) +from hubgw.core.errors import NotFoundError, AlreadyExistsError + + +class WarpsService: + """Warps service for business logic.""" + + def __init__(self, session: AsyncSession): + self.repo = WarpsRepository(session) + + async def create_warp(self, request: WarpCreateRequest) -> Warp: + """Create warp with business logic.""" + # Check if warp with same name already exists + existing = await self.repo.get_by_request(WarpGetRequest(name=request.name)) + if existing: + raise AlreadyExistsError(f"Warp '{request.name}' already exists") + + return await self.repo.create(request) + + async def update_warp(self, request: WarpUpdateRequest) -> Warp: + """Update warp with business logic.""" + warp = await self.repo.update(request) + if not warp: + raise NotFoundError(f"Warp '{request.name}' not found") + + return warp + + async def delete_warp(self, request: WarpDeleteRequest) -> None: + """Delete warp with business logic.""" + success = await self.repo.delete(request) + if not success: + raise NotFoundError(f"Warp '{request.name}' not found") + + async def get_warp(self, request: WarpGetRequest) -> WarpGetResponse: + """Get warp with business logic.""" + warp = await self.repo.get_by_request(request) + if not warp: + raise NotFoundError(f"Warp '{request.name}' not found") + + return WarpGetResponse( + id=warp.id, + name=warp.name, + world=warp.world, + x=warp.x, + y=warp.y, + z=warp.z, + yaw=warp.yaw, + pitch=warp.pitch, + is_public=warp.is_public, + description=warp.description, + created_at=warp.created_at, + updated_at=warp.updated_at + ) + + async def list_warps(self, query: WarpListQuery) -> WarpListResponse: + """List warps with business logic.""" + warps, total = await self.repo.list(query) + + warp_list = [ + Warp( + id=warp.id, + name=warp.name, + world=warp.world, + x=warp.x, + y=warp.y, + z=warp.z, + yaw=warp.yaw, + pitch=warp.pitch, + is_public=warp.is_public, + description=warp.description, + created_at=warp.created_at, + updated_at=warp.updated_at + ) + for warp in warps + ] + + pages = (total + query.size - 1) // query.size + + return WarpListResponse( + warps=warp_list, + total=total, + page=query.page, + size=query.size, + pages=pages + ) diff --git a/src/hubgw/services/whitelist_service.py b/src/hubgw/services/whitelist_service.py new file mode 100644 index 0000000..16afc46 --- /dev/null +++ b/src/hubgw/services/whitelist_service.py @@ -0,0 +1,63 @@ +"""Whitelist service.""" + +from sqlalchemy.ext.asyncio import AsyncSession +from typing import List + +from hubgw.repositories.whitelist_repo import WhitelistRepository +from hubgw.schemas.whitelist import ( + WhitelistAddRequest, WhitelistRemoveRequest, WhitelistCheckRequest, + WhitelistEntry, WhitelistCheckResponse, WhitelistListResponse +) +from hubgw.core.errors import AlreadyExistsError, NotFoundError + + +class WhitelistService: + """Whitelist service for business logic.""" + + def __init__(self, session: AsyncSession): + self.repo = WhitelistRepository(session) + + async def add_player(self, request: WhitelistAddRequest) -> WhitelistEntry: + """Add player to whitelist with business logic.""" + # Check if player is already whitelisted + existing = await self.repo.check(WhitelistCheckRequest(player_name=request.player_name)) + if existing: + raise AlreadyExistsError(f"Player '{request.player_name}' is already whitelisted") + + return await self.repo.add(request) + + async def remove_player(self, request: WhitelistRemoveRequest) -> None: + """Remove player from whitelist with business logic.""" + success = await self.repo.remove(request) + if not success: + raise NotFoundError(f"Player '{request.player_name}' not found in whitelist") + + async def check_player(self, request: WhitelistCheckRequest) -> WhitelistCheckResponse: + """Check if player is whitelisted.""" + entry = await self.repo.check(request) + + return WhitelistCheckResponse( + is_whitelisted=entry is not None, + player_uuid=entry.player_uuid if entry else None + ) + + async def list_players(self) -> WhitelistListResponse: + """List all whitelisted players.""" + entries = await self.repo.list_all() + total = await self.repo.count() + + entry_list = [ + WhitelistEntry( + id=entry.id, + player_name=entry.player_name, + player_uuid=entry.player_uuid, + added_by=entry.added_by, + added_at=entry.added_at, + reason=entry.reason, + created_at=entry.created_at, + updated_at=entry.updated_at + ) + for entry in entries + ] + + return WhitelistListResponse(entries=entry_list, total=total) diff --git a/src/hubgw/utils/__init__.py b/src/hubgw/utils/__init__.py new file mode 100644 index 0000000..eadaf54 --- /dev/null +++ b/src/hubgw/utils/__init__.py @@ -0,0 +1 @@ +"""Utility functions for hubgw.""" diff --git a/src/hubgw/utils/pagination.py b/src/hubgw/utils/pagination.py new file mode 100644 index 0000000..ff88588 --- /dev/null +++ b/src/hubgw/utils/pagination.py @@ -0,0 +1,37 @@ +"""Pagination utility functions.""" + +from typing import List, TypeVar, Generic +from math import ceil + +T = TypeVar('T') + + +class PaginatedResult(Generic[T]): + """Paginated result container.""" + + def __init__(self, items: List[T], total: int, page: int, size: int): + self.items = items + self.total = total + self.page = page + self.size = size + self.pages = ceil(total / size) if size > 0 else 0 + + def to_dict(self) -> dict: + """Convert to dictionary.""" + return { + "items": self.items, + "total": self.total, + "page": self.page, + "size": self.size, + "pages": self.pages + } + + +def calculate_offset(page: int, size: int) -> int: + """Calculate offset for pagination.""" + return (page - 1) * size + + +def calculate_pages(total: int, size: int) -> int: + """Calculate total pages.""" + return ceil(total / size) if size > 0 else 0 diff --git a/src/hubgw/utils/time.py b/src/hubgw/utils/time.py new file mode 100644 index 0000000..83a90fd --- /dev/null +++ b/src/hubgw/utils/time.py @@ -0,0 +1,24 @@ +"""Time utility functions.""" + +from datetime import datetime, timezone +from typing import Optional + + +def utc_now() -> datetime: + """Get current UTC datetime.""" + return datetime.now(timezone.utc) + + +def is_expired(expires_at: Optional[datetime]) -> bool: + """Check if datetime is expired.""" + if expires_at is None: + return False + return expires_at < utc_now() + + +def seconds_until(expires_at: Optional[datetime]) -> int: + """Get seconds until datetime expires.""" + if expires_at is None: + return 0 + delta = expires_at - utc_now() + return max(0, int(delta.total_seconds())) diff --git a/src/hubgw/utils/uuid.py b/src/hubgw/utils/uuid.py new file mode 100644 index 0000000..d652944 --- /dev/null +++ b/src/hubgw/utils/uuid.py @@ -0,0 +1,25 @@ +"""UUID utility functions.""" + +import uuid +from typing import Union + + +def generate_uuid() -> str: + """Generate a new UUID string.""" + return str(uuid.uuid4()) + + +def is_valid_uuid(uuid_string: str) -> bool: + """Check if string is a valid UUID.""" + try: + uuid.UUID(uuid_string) + return True + except ValueError: + return False + + +def parse_uuid(uuid_string: Union[str, uuid.UUID]) -> uuid.UUID: + """Parse UUID from string or return UUID object.""" + if isinstance(uuid_string, uuid.UUID): + return uuid_string + return uuid.UUID(uuid_string) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..12b09cf --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for hubgw.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3028dd1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,54 @@ +"""Pytest configuration and fixtures.""" + +import pytest +import asyncio +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from fastapi.testclient import TestClient + +from hubgw.main import create_app +from hubgw.context import AppContext +from hubgw.core.config import AppSettings + + +@pytest.fixture(scope="session") +def event_loop(): + """Create event loop for async tests.""" + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + +@pytest.fixture +async def test_db(): + """Create test database engine.""" + settings = AppSettings() + settings.DB_DSN = "postgresql+asyncpg://test:test@localhost:5432/hubgw_test" + + engine = create_async_engine(settings.DB_DSN) + session_factory = async_sessionmaker(engine, expire_on_commit=False) + + yield engine, session_factory + + await engine.dispose() + + +@pytest.fixture +async def test_session(test_db): + """Create test database session.""" + engine, session_factory = test_db + + async with session_factory() as session: + yield session + + +@pytest.fixture +def test_client(): + """Create test client.""" + app = create_app() + return TestClient(app) + + +@pytest.fixture +def test_context(): + """Create test context.""" + return AppContext() diff --git a/tests/test_api_v1_audit.py b/tests/test_api_v1_audit.py new file mode 100644 index 0000000..8842283 --- /dev/null +++ b/tests/test_api_v1_audit.py @@ -0,0 +1,19 @@ +"""Tests for audit endpoints.""" + +import pytest +from fastapi.testclient import TestClient +from uuid import uuid4 +from datetime import datetime + + +def test_log_command_unauthorized(test_client: TestClient): + """Test log command without API key.""" + response = test_client.post("/api/v1/audit/commands", json={ + "player_uuid": str(uuid4()), + "player_name": "test_player", + "command": "tp", + "arguments": ["player2"], + "server": "hub", + "timestamp": datetime.utcnow().isoformat() + }) + assert response.status_code == 401 diff --git a/tests/test_api_v1_cooldowns.py b/tests/test_api_v1_cooldowns.py new file mode 100644 index 0000000..7e90038 --- /dev/null +++ b/tests/test_api_v1_cooldowns.py @@ -0,0 +1,22 @@ +"""Tests for cooldowns endpoints.""" + +import pytest +from fastapi.testclient import TestClient +from uuid import uuid4 + + +def test_check_cooldown_unauthorized(test_client: TestClient): + """Test check cooldown without API key.""" + response = test_client.post("/api/v1/cooldowns/check", json={ + "player_uuid": str(uuid4()), + "key": "test_key" + }) + assert response.status_code == 401 + + +def test_touch_cooldown_unauthorized(test_client: TestClient): + """Test touch cooldown without API key.""" + response = test_client.put("/api/v1/cooldowns/touch?seconds=60", json={ + "key": "test_key" + }) + assert response.status_code == 401 diff --git a/tests/test_api_v1_health.py b/tests/test_api_v1_health.py new file mode 100644 index 0000000..0d4277b --- /dev/null +++ b/tests/test_api_v1_health.py @@ -0,0 +1,11 @@ +"""Tests for health endpoints.""" + +import pytest +from fastapi.testclient import TestClient + + +def test_health_check(test_client: TestClient): + """Test health check endpoint.""" + response = test_client.get("/api/v1/health/") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} diff --git a/tests/test_api_v1_homes.py b/tests/test_api_v1_homes.py new file mode 100644 index 0000000..98baf0b --- /dev/null +++ b/tests/test_api_v1_homes.py @@ -0,0 +1,33 @@ +"""Tests for homes endpoints.""" + +import pytest +from fastapi.testclient import TestClient +from uuid import uuid4 + + +def test_upsert_home_unauthorized(test_client: TestClient): + """Test upsert home without API key.""" + response = test_client.put("/api/v1/homes/", json={ + "player_uuid": str(uuid4()), + "name": "test_home", + "world": "world", + "x": 0.0, + "y": 64.0, + "z": 0.0 + }) + assert response.status_code == 401 + + +def test_get_home_unauthorized(test_client: TestClient): + """Test get home without API key.""" + response = test_client.post("/api/v1/homes/get", json={ + "player_uuid": str(uuid4()), + "name": "test_home" + }) + assert response.status_code == 401 + + +def test_list_homes_unauthorized(test_client: TestClient): + """Test list homes without API key.""" + response = test_client.get(f"/api/v1/homes/{uuid4()}") + assert response.status_code == 401 diff --git a/tests/test_api_v1_kits.py b/tests/test_api_v1_kits.py new file mode 100644 index 0000000..45de320 --- /dev/null +++ b/tests/test_api_v1_kits.py @@ -0,0 +1,14 @@ +"""Tests for kits endpoints.""" + +import pytest +from fastapi.testclient import TestClient +from uuid import uuid4 + + +def test_claim_kit_unauthorized(test_client: TestClient): + """Test claim kit without API key.""" + response = test_client.post("/api/v1/kits/claim", json={ + "player_uuid": str(uuid4()), + "kit_name": "starter" + }) + assert response.status_code == 401 diff --git a/tests/test_api_v1_punishments.py b/tests/test_api_v1_punishments.py new file mode 100644 index 0000000..ae407dd --- /dev/null +++ b/tests/test_api_v1_punishments.py @@ -0,0 +1,49 @@ +"""Tests for punishments endpoints.""" + +import pytest +from fastapi.testclient import TestClient +from uuid import uuid4 + + +def test_create_punishment_unauthorized(test_client: TestClient): + """Test create punishment without API key.""" + response = test_client.post("/api/v1/punishments/", json={ + "player_uuid": str(uuid4()), + "player_name": "test_player", + "punishment_type": "ban", + "reason": "Test ban", + "staff_uuid": str(uuid4()), + "staff_name": "admin" + }) + assert response.status_code == 401 + + +def test_revoke_punishment_unauthorized(test_client: TestClient): + """Test revoke punishment without API key.""" + response = test_client.post("/api/v1/punishments/revoke", json={ + "punishment_id": str(uuid4()), + "revoked_by": str(uuid4()), + "revoked_reason": "Test revoke" + }) + assert response.status_code == 401 + + +def test_query_punishments_unauthorized(test_client: TestClient): + """Test query punishments without API key.""" + response = test_client.post("/api/v1/punishments/query", json={ + "page": 1, + "size": 20 + }) + assert response.status_code == 401 + + +def test_get_ban_status_unauthorized(test_client: TestClient): + """Test get ban status without API key.""" + response = test_client.get(f"/api/v1/punishments/ban/{uuid4()}") + assert response.status_code == 401 + + +def test_get_mute_status_unauthorized(test_client: TestClient): + """Test get mute status without API key.""" + response = test_client.get(f"/api/v1/punishments/mute/{uuid4()}") + assert response.status_code == 401 diff --git a/tests/test_api_v1_warps.py b/tests/test_api_v1_warps.py new file mode 100644 index 0000000..b0a5ae6 --- /dev/null +++ b/tests/test_api_v1_warps.py @@ -0,0 +1,50 @@ +"""Tests for warps endpoints.""" + +import pytest +from fastapi.testclient import TestClient + + +def test_create_warp_unauthorized(test_client: TestClient): + """Test create warp without API key.""" + response = test_client.post("/api/v1/warps/", json={ + "name": "test_warp", + "world": "world", + "x": 0.0, + "y": 64.0, + "z": 0.0 + }) + assert response.status_code == 401 + + +def test_update_warp_unauthorized(test_client: TestClient): + """Test update warp without API key.""" + response = test_client.patch("/api/v1/warps/", json={ + "name": "test_warp", + "world": "world_nether" + }) + assert response.status_code == 401 + + +def test_delete_warp_unauthorized(test_client: TestClient): + """Test delete warp without API key.""" + response = test_client.delete("/api/v1/warps/", json={ + "name": "test_warp" + }) + assert response.status_code == 401 + + +def test_get_warp_unauthorized(test_client: TestClient): + """Test get warp without API key.""" + response = test_client.post("/api/v1/warps/get", json={ + "name": "test_warp" + }) + assert response.status_code == 401 + + +def test_list_warps_unauthorized(test_client: TestClient): + """Test list warps without API key.""" + response = test_client.post("/api/v1/warps/list", json={ + "page": 1, + "size": 20 + }) + assert response.status_code == 401 diff --git a/tests/test_api_v1_whitelist.py b/tests/test_api_v1_whitelist.py new file mode 100644 index 0000000..e487178 --- /dev/null +++ b/tests/test_api_v1_whitelist.py @@ -0,0 +1,37 @@ +"""Tests for whitelist endpoints.""" + +import pytest +from fastapi.testclient import TestClient +from uuid import uuid4 + + +def test_add_player_unauthorized(test_client: TestClient): + """Test add player without API key.""" + response = test_client.post("/api/v1/whitelist/add", json={ + "player_name": "test_player", + "player_uuid": str(uuid4()), + "added_by": "admin" + }) + assert response.status_code == 401 + + +def test_remove_player_unauthorized(test_client: TestClient): + """Test remove player without API key.""" + response = test_client.post("/api/v1/whitelist/remove", json={ + "player_name": "test_player" + }) + assert response.status_code == 401 + + +def test_check_player_unauthorized(test_client: TestClient): + """Test check player without API key.""" + response = test_client.post("/api/v1/whitelist/check", json={ + "player_name": "test_player" + }) + assert response.status_code == 401 + + +def test_list_players_unauthorized(test_client: TestClient): + """Test list players without API key.""" + response = test_client.get("/api/v1/whitelist/") + assert response.status_code == 401 diff --git a/tests/test_repos_homes.py b/tests/test_repos_homes.py new file mode 100644 index 0000000..1e77182 --- /dev/null +++ b/tests/test_repos_homes.py @@ -0,0 +1,48 @@ +"""Tests for homes repository.""" + +import pytest +from uuid import uuid4 +from hubgw.repositories.homes_repo import HomesRepository +from hubgw.schemas.homes import HomeUpsertRequest, HomeGetRequest + + +@pytest.mark.asyncio +async def test_upsert_home(test_session): + """Test upsert home.""" + repo = HomesRepository(test_session) + + request = HomeUpsertRequest( + player_uuid=uuid4(), + name="test_home", + world="world", + x=0.0, + y=64.0, + z=0.0 + ) + + home = await repo.upsert(request) + assert home.name == "test_home" + assert home.player_uuid == request.player_uuid + + +@pytest.mark.asyncio +async def test_get_home_not_found(test_session): + """Test get home when not found.""" + repo = HomesRepository(test_session) + + request = HomeGetRequest( + player_uuid=uuid4(), + name="nonexistent_home" + ) + + home = await repo.get_by_request(request) + assert home is None + + +@pytest.mark.asyncio +async def test_list_homes_empty(test_session): + """Test list homes when empty.""" + repo = HomesRepository(test_session) + + homes = await repo.list_by_player(uuid4()) + assert homes == [] diff --git a/tests/test_repos_punishments.py b/tests/test_repos_punishments.py new file mode 100644 index 0000000..96d9523 --- /dev/null +++ b/tests/test_repos_punishments.py @@ -0,0 +1,56 @@ +"""Tests for punishments repository.""" + +import pytest +from uuid import uuid4 +from datetime import datetime +from hubgw.repositories.punishments_repo import PunishmentsRepository +from hubgw.schemas.punishments import PunishmentCreateRequest, PunishmentRevokeRequest, PunishmentQuery + + +@pytest.mark.asyncio +async def test_create_punishment(test_session): + """Test create punishment.""" + repo = PunishmentsRepository(test_session) + + request = PunishmentCreateRequest( + player_uuid=uuid4(), + player_name="test_player", + punishment_type="ban", + reason="Test ban", + staff_uuid=uuid4(), + staff_name="admin" + ) + + punishment = await repo.create(request) + assert punishment.player_name == "test_player" + assert punishment.punishment_type == "ban" + + +@pytest.mark.asyncio +async def test_query_punishments_empty(test_session): + """Test query punishments when empty.""" + repo = PunishmentsRepository(test_session) + + query = PunishmentQuery(page=1, size=20) + punishments, total = await repo.query(query) + + assert punishments == [] + assert total == 0 + + +@pytest.mark.asyncio +async def test_get_active_ban_not_found(test_session): + """Test get active ban when not found.""" + repo = PunishmentsRepository(test_session) + + ban = await repo.get_active_ban(uuid4()) + assert ban is None + + +@pytest.mark.asyncio +async def test_get_active_mute_not_found(test_session): + """Test get active mute when not found.""" + repo = PunishmentsRepository(test_session) + + mute = await repo.get_active_mute(uuid4()) + assert mute is None diff --git a/tests/test_repos_warps.py b/tests/test_repos_warps.py new file mode 100644 index 0000000..fac8334 --- /dev/null +++ b/tests/test_repos_warps.py @@ -0,0 +1,46 @@ +"""Tests for warps repository.""" + +import pytest +from hubgw.repositories.warps_repo import WarpsRepository +from hubgw.schemas.warps import WarpCreateRequest, WarpUpdateRequest, WarpDeleteRequest, WarpGetRequest, WarpListQuery + + +@pytest.mark.asyncio +async def test_create_warp(test_session): + """Test create warp.""" + repo = WarpsRepository(test_session) + + request = WarpCreateRequest( + name="test_warp", + world="world", + x=0.0, + y=64.0, + z=0.0 + ) + + warp = await repo.create(request) + assert warp.name == "test_warp" + assert warp.world == "world" + + +@pytest.mark.asyncio +async def test_get_warp_not_found(test_session): + """Test get warp when not found.""" + repo = WarpsRepository(test_session) + + request = WarpGetRequest(name="nonexistent_warp") + + warp = await repo.get_by_request(request) + assert warp is None + + +@pytest.mark.asyncio +async def test_list_warps_empty(test_session): + """Test list warps when empty.""" + repo = WarpsRepository(test_session) + + query = WarpListQuery(page=1, size=20) + warps, total = await repo.list(query) + + assert warps == [] + assert total == 0 diff --git a/tests/test_repos_whitelist.py b/tests/test_repos_whitelist.py new file mode 100644 index 0000000..a4877ae --- /dev/null +++ b/tests/test_repos_whitelist.py @@ -0,0 +1,42 @@ +"""Tests for whitelist repository.""" + +import pytest +from uuid import uuid4 +from hubgw.repositories.whitelist_repo import WhitelistRepository +from hubgw.schemas.whitelist import WhitelistAddRequest, WhitelistRemoveRequest, WhitelistCheckRequest + + +@pytest.mark.asyncio +async def test_add_player(test_session): + """Test add player to whitelist.""" + repo = WhitelistRepository(test_session) + + request = WhitelistAddRequest( + player_name="test_player", + player_uuid=uuid4(), + added_by="admin" + ) + + entry = await repo.add(request) + assert entry.player_name == "test_player" + assert entry.added_by == "admin" + + +@pytest.mark.asyncio +async def test_check_player_not_found(test_session): + """Test check player when not found.""" + repo = WhitelistRepository(test_session) + + request = WhitelistCheckRequest(player_name="nonexistent_player") + + entry = await repo.check(request) + assert entry is None + + +@pytest.mark.asyncio +async def test_list_players_empty(test_session): + """Test list players when empty.""" + repo = WhitelistRepository(test_session) + + entries = await repo.list_all() + assert entries == []