feat: Initialize frontend and backend structure with essential configurations
- Added TypeScript build info for frontend. - Created Vite configuration for React application. - Implemented pre-commit hook to run checks before commits. - Set up PostgreSQL Dockerfile with PostGIS support and initialization scripts. - Added database creation script for PostgreSQL with necessary extensions. - Established Python project configuration with dependencies and development tools. - Developed pre-commit script to enforce code quality checks for backend and frontend. - Created PowerShell script to set up Git hooks path.
This commit is contained in:
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
35
backend/alembic.ini
Normal file
35
backend/alembic.ini
Normal file
@@ -0,0 +1,35 @@
|
||||
[alembic]
|
||||
script_location = migrations
|
||||
sqlalchemy.url = postgresql+psycopg://railgame:railgame@localhost:5432/railgame
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stdout,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
10
backend/app/api/__init__.py
Normal file
10
backend/app/api/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from backend.app.api.auth import router as auth_router
|
||||
from backend.app.api.health import router as health_router
|
||||
from backend.app.api.network import router as network_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(health_router, tags=["health"])
|
||||
router.include_router(auth_router)
|
||||
router.include_router(network_router)
|
||||
41
backend/app/api/auth.py
Normal file
41
backend/app/api/auth.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from backend.app.api.deps import get_current_user
|
||||
from backend.app.models import AuthResponse, LoginRequest, RegisterRequest, UserPublic
|
||||
from backend.app.services.auth import (
|
||||
authenticate_user,
|
||||
issue_token_for_user,
|
||||
register_user,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=AuthResponse)
|
||||
async def login(credentials: LoginRequest) -> AuthResponse:
|
||||
user = authenticate_user(credentials.username, credentials.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return issue_token_for_user(user)
|
||||
|
||||
|
||||
@router.post("/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(payload: RegisterRequest) -> AuthResponse:
|
||||
try:
|
||||
user = register_user(payload.username, payload.password, payload.full_name)
|
||||
except ValueError as exc:
|
||||
message = str(exc)
|
||||
status_code = status.HTTP_409_CONFLICT if "exists" in message else status.HTTP_400_BAD_REQUEST
|
||||
raise HTTPException(status_code=status_code, detail=message) from exc
|
||||
return issue_token_for_user(user)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserPublic)
|
||||
async def read_current_user(current_user: UserPublic = Depends(get_current_user)) -> UserPublic:
|
||||
return current_user
|
||||
40
backend/app/api/deps.py
Normal file
40
backend/app/api/deps.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.app.core.security import decode_access_token
|
||||
from backend.app.db.session import get_db_session
|
||||
from backend.app.models import TokenPayload, UserPublic
|
||||
from backend.app.services.auth import get_user, to_public_user
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
|
||||
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme)) -> UserPublic:
|
||||
try:
|
||||
payload = TokenPayload(**decode_access_token(token))
|
||||
except (ValueError, ValidationError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
) from exc
|
||||
|
||||
user = get_user(payload.sub)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return to_public_user(user)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
yield from get_db_session()
|
||||
8
backend/app/api/health.py
Normal file
8
backend/app/api/health.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health", summary="Service health status")
|
||||
async def health_check() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
16
backend/app/api/network.py
Normal file
16
backend/app/api/network.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.app.api.deps import get_current_user, get_db
|
||||
from backend.app.models import UserPublic
|
||||
from backend.app.services.network import get_network_snapshot
|
||||
|
||||
router = APIRouter(prefix="/network", tags=["network"])
|
||||
|
||||
|
||||
@router.get("", summary="Fetch a snapshot of the railway network")
|
||||
def read_network_snapshot(
|
||||
_: UserPublic = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, list[dict[str, object]]]:
|
||||
return get_network_snapshot(db)
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
38
backend/app/core/config.py
Normal file
38
backend/app/core/config.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
project_name: str = "Rail Game API"
|
||||
version: str = "0.1.0"
|
||||
api_prefix: str = "/api"
|
||||
jwt_secret_key: str = "insecure-change-me"
|
||||
jwt_algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 60
|
||||
database_url: str = "postgresql+psycopg://railgame:railgame@localhost:5432/railgame"
|
||||
database_echo: bool = False
|
||||
test_database_url: Optional[str] = None
|
||||
alembic_database_url: Optional[str] = None
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
|
||||
@property
|
||||
def sqlalchemy_database_url(self) -> str:
|
||||
return self.database_url
|
||||
|
||||
@property
|
||||
def sqlalchemy_test_url(self) -> Optional[str]:
|
||||
return self.test_database_url
|
||||
|
||||
@property
|
||||
def sqlalchemy_alembic_url(self) -> str:
|
||||
return self.alembic_database_url or self.database_url
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
36
backend/app/core/security.py
Normal file
36
backend/app/core/security.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from backend.app.core.config import get_settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(subject: str, expires_delta: timedelta | None = None) -> str:
|
||||
settings = get_settings()
|
||||
expire = datetime.now(timezone.utc) + (
|
||||
expires_delta or timedelta(minutes=settings.access_token_expire_minutes)
|
||||
)
|
||||
to_encode: Dict[str, Any] = {"sub": subject, "exp": expire}
|
||||
return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> Dict[str, Any]:
|
||||
settings = get_settings()
|
||||
try:
|
||||
return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
||||
except JWTError as exc: # pragma: no cover - specific error mapping handled by caller
|
||||
raise ValueError("Invalid token") from exc
|
||||
6
backend/app/db/__init__.py
Normal file
6
backend/app/db/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Database package exposing SQLAlchemy base metadata and session utilities."""
|
||||
|
||||
from backend.app.db.models import Base
|
||||
from backend.app.db.session import SessionLocal, engine, get_db_session
|
||||
|
||||
__all__ = ["Base", "SessionLocal", "engine", "get_db_session"]
|
||||
90
backend/app/db/models.py
Normal file
90
backend/app/db/models.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from geoalchemy2 import Geometry
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all SQLAlchemy models."""
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[DateTime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[DateTime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
|
||||
class User(Base, TimestampMixin):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
email: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True)
|
||||
full_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(32), nullable=False, default="player")
|
||||
preferences: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class Station(Base, TimestampMixin):
|
||||
__tablename__ = "stations"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
osm_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
code: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
location: Mapped[str] = mapped_column(Geometry(geometry_type="POINT", srid=4326), nullable=False)
|
||||
elevation_m: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
|
||||
class Track(Base, TimestampMixin):
|
||||
__tablename__ = "tracks"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
start_station_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("stations.id", ondelete="RESTRICT"), nullable=False)
|
||||
end_station_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("stations.id", ondelete="RESTRICT"), nullable=False)
|
||||
length_meters: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
|
||||
max_speed_kph: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
is_bidirectional: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="planned")
|
||||
track_geometry: Mapped[str] = mapped_column(Geometry(geometry_type="LINESTRING", srid=4326), nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("start_station_id", "end_station_id", name="uq_tracks_station_pair"),
|
||||
)
|
||||
|
||||
|
||||
class Train(Base, TimestampMixin):
|
||||
__tablename__ = "trains"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
designation: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
operator_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
|
||||
home_station_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("stations.id", ondelete="SET NULL"))
|
||||
capacity: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
max_speed_kph: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
consist: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class TrainSchedule(Base, TimestampMixin):
|
||||
__tablename__ = "train_schedules"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
train_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("trains.id", ondelete="CASCADE"), nullable=False)
|
||||
sequence_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
station_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("stations.id", ondelete="CASCADE"), nullable=False)
|
||||
scheduled_arrival: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
scheduled_departure: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
dwell_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("train_id", "sequence_index", name="uq_train_schedule_sequence"),
|
||||
)
|
||||
24
backend/app/db/session.py
Normal file
24
backend/app/db/session.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from backend.app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine = create_engine(settings.sqlalchemy_database_url, echo=settings.database_echo, future=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
||||
|
||||
|
||||
def get_db_session() -> Generator[Session, None, None]:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
__all__ = ["engine", "SessionLocal", "get_db_session"]
|
||||
14
backend/app/main.py
Normal file
14
backend/app/main.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from backend.app.api import router as api_router
|
||||
from backend.app.core.config import get_settings
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
application = FastAPI(title=settings.project_name, version=settings.version)
|
||||
application.include_router(api_router, prefix=settings.api_prefix)
|
||||
return application
|
||||
|
||||
|
||||
app = create_app()
|
||||
35
backend/app/models/__init__.py
Normal file
35
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from .auth import (
|
||||
AuthResponse,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
TokenPayload,
|
||||
TokenResponse,
|
||||
UserInDB,
|
||||
UserPublic,
|
||||
)
|
||||
from .base import (
|
||||
StationCreate,
|
||||
StationModel,
|
||||
TrackCreate,
|
||||
TrackModel,
|
||||
TrainCreate,
|
||||
TrainModel,
|
||||
to_camel,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LoginRequest",
|
||||
"RegisterRequest",
|
||||
"AuthResponse",
|
||||
"TokenPayload",
|
||||
"TokenResponse",
|
||||
"UserInDB",
|
||||
"UserPublic",
|
||||
"StationCreate",
|
||||
"StationModel",
|
||||
"TrackCreate",
|
||||
"TrackModel",
|
||||
"TrainCreate",
|
||||
"TrainModel",
|
||||
"to_camel",
|
||||
]
|
||||
45
backend/app/models/auth.py
Normal file
45
backend/app/models/auth.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from backend.app.models.base import to_camel
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
full_name: str | None = None
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
sub: str
|
||||
exp: int
|
||||
|
||||
|
||||
class UserPublic(BaseModel):
|
||||
username: str
|
||||
full_name: str | None = None
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
||||
|
||||
|
||||
class UserInDB(UserPublic):
|
||||
hashed_password: str
|
||||
|
||||
|
||||
class AuthResponse(TokenResponse):
|
||||
user: UserPublic
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
||||
87
backend/app/models/base.py
Normal file
87
backend/app/models/base.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Generic, Sequence, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
def to_camel(string: str) -> str:
|
||||
head, *tail = string.split("_")
|
||||
return head + "".join(part.capitalize() for part in tail)
|
||||
|
||||
IdT = TypeVar("IdT", bound=str)
|
||||
|
||||
|
||||
class CamelModel(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
populate_by_name=True,
|
||||
alias_generator=to_camel,
|
||||
)
|
||||
|
||||
|
||||
class TimestampedModel(CamelModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(
|
||||
frozen=True,
|
||||
from_attributes=True,
|
||||
populate_by_name=True,
|
||||
alias_generator=to_camel,
|
||||
)
|
||||
|
||||
|
||||
class IdentifiedModel(TimestampedModel, Generic[IdT]):
|
||||
id: IdT
|
||||
|
||||
|
||||
class StationModel(IdentifiedModel[str]):
|
||||
name: str
|
||||
latitude: float
|
||||
longitude: float
|
||||
|
||||
|
||||
class TrackModel(IdentifiedModel[str]):
|
||||
start_station_id: str
|
||||
end_station_id: str
|
||||
length_meters: float
|
||||
max_speed_kph: float
|
||||
|
||||
|
||||
class TrainModel(IdentifiedModel[str]):
|
||||
designation: str
|
||||
capacity: int
|
||||
max_speed_kph: float
|
||||
operating_track_ids: list[str]
|
||||
|
||||
|
||||
class StationCreate(CamelModel):
|
||||
name: str
|
||||
latitude: float
|
||||
longitude: float
|
||||
osm_id: str | None = None
|
||||
code: str | None = None
|
||||
elevation_m: float | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class TrackCreate(CamelModel):
|
||||
start_station_id: str
|
||||
end_station_id: str
|
||||
coordinates: Sequence[tuple[float, float]]
|
||||
name: str | None = None
|
||||
length_meters: float | None = None
|
||||
max_speed_kph: int | None = None
|
||||
is_bidirectional: bool = True
|
||||
status: str = "planned"
|
||||
|
||||
|
||||
class TrainCreate(CamelModel):
|
||||
designation: str
|
||||
capacity: int
|
||||
max_speed_kph: int
|
||||
operator_id: str | None = None
|
||||
home_station_id: str | None = None
|
||||
consist: str | None = None
|
||||
11
backend/app/repositories/__init__.py
Normal file
11
backend/app/repositories/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Repository abstractions for database access."""
|
||||
|
||||
from backend.app.repositories.stations import StationRepository
|
||||
from backend.app.repositories.tracks import TrackRepository
|
||||
from backend.app.repositories.trains import TrainRepository
|
||||
|
||||
__all__ = [
|
||||
"StationRepository",
|
||||
"TrackRepository",
|
||||
"TrainRepository",
|
||||
]
|
||||
39
backend/app/repositories/base.py
Normal file
39
backend/app/repositories/base.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from typing import Generic, Iterable, Optional, Sequence, Type, TypeVar
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.app.db.models import Base
|
||||
|
||||
ModelT = TypeVar("ModelT", bound=Base)
|
||||
|
||||
|
||||
class BaseRepository(Generic[ModelT]):
|
||||
"""Provide common CRUD helpers for SQLAlchemy models."""
|
||||
|
||||
model: Type[ModelT]
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def get(self, identifier: object) -> Optional[ModelT]:
|
||||
return self.session.get(self.model, identifier)
|
||||
|
||||
def list(self) -> Sequence[ModelT]:
|
||||
statement = sa.select(self.model)
|
||||
return list(self.session.scalars(statement))
|
||||
|
||||
def add(self, instance: ModelT) -> ModelT:
|
||||
self.session.add(instance)
|
||||
return instance
|
||||
|
||||
def add_all(self, instances: Iterable[ModelT]) -> None:
|
||||
self.session.add_all(instances)
|
||||
|
||||
def delete(self, instance: ModelT) -> None:
|
||||
self.session.delete(instance)
|
||||
|
||||
def flush(self) -> None:
|
||||
self.session.flush()
|
||||
37
backend/app/repositories/stations.py
Normal file
37
backend/app/repositories/stations.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from geoalchemy2.elements import WKTElement
|
||||
|
||||
from backend.app.db.models import Station
|
||||
from backend.app.repositories.base import BaseRepository
|
||||
from backend.app.models import StationCreate
|
||||
|
||||
|
||||
class StationRepository(BaseRepository[Station]):
|
||||
model = Station
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
super().__init__(session)
|
||||
|
||||
def list_active(self) -> list[Station]:
|
||||
statement = sa.select(self.model).where(self.model.is_active.is_(True))
|
||||
return list(self.session.scalars(statement))
|
||||
|
||||
@staticmethod
|
||||
def _point(latitude: float, longitude: float) -> WKTElement:
|
||||
return WKTElement(f"POINT({longitude} {latitude})", srid=4326)
|
||||
|
||||
def create(self, data: StationCreate) -> Station:
|
||||
station = Station(
|
||||
name=data.name,
|
||||
osm_id=data.osm_id,
|
||||
code=data.code,
|
||||
location=self._point(data.latitude, data.longitude),
|
||||
elevation_m=data.elevation_m,
|
||||
is_active=data.is_active,
|
||||
)
|
||||
self.session.add(station)
|
||||
return station
|
||||
50
backend/app/repositories/tracks.py
Normal file
50
backend/app/repositories/tracks.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from geoalchemy2.elements import WKTElement
|
||||
from uuid import UUID
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.app.db.models import Track
|
||||
from backend.app.repositories.base import BaseRepository
|
||||
from backend.app.models import TrackCreate
|
||||
|
||||
|
||||
class TrackRepository(BaseRepository[Track]):
|
||||
model = Track
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
super().__init__(session)
|
||||
|
||||
def list_all(self) -> list[Track]:
|
||||
statement = sa.select(self.model)
|
||||
return list(self.session.scalars(statement))
|
||||
|
||||
@staticmethod
|
||||
def _ensure_uuid(value: UUID | str) -> UUID:
|
||||
if isinstance(value, UUID):
|
||||
return value
|
||||
return UUID(str(value))
|
||||
|
||||
@staticmethod
|
||||
def _line_string(coordinates: list[tuple[float, float]]) -> WKTElement:
|
||||
if len(coordinates) < 2:
|
||||
raise ValueError("Track geometry requires at least two coordinate pairs")
|
||||
parts = [f"{lon} {lat}" for lat, lon in coordinates]
|
||||
return WKTElement(f"LINESTRING({', '.join(parts)})", srid=4326)
|
||||
|
||||
def create(self, data: TrackCreate) -> Track:
|
||||
coordinates = list(data.coordinates)
|
||||
geometry = self._line_string(coordinates)
|
||||
track = Track(
|
||||
name=data.name,
|
||||
start_station_id=self._ensure_uuid(data.start_station_id),
|
||||
end_station_id=self._ensure_uuid(data.end_station_id),
|
||||
length_meters=data.length_meters,
|
||||
max_speed_kph=data.max_speed_kph,
|
||||
is_bidirectional=data.is_bidirectional,
|
||||
status=data.status,
|
||||
track_geometry=geometry,
|
||||
)
|
||||
self.session.add(track)
|
||||
return track
|
||||
40
backend/app/repositories/trains.py
Normal file
40
backend/app/repositories/trains.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from uuid import UUID
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.app.db.models import Train
|
||||
from backend.app.repositories.base import BaseRepository
|
||||
from backend.app.models import TrainCreate
|
||||
|
||||
|
||||
class TrainRepository(BaseRepository[Train]):
|
||||
model = Train
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
super().__init__(session)
|
||||
|
||||
def list_all(self) -> list[Train]:
|
||||
statement = sa.select(self.model)
|
||||
return list(self.session.scalars(statement))
|
||||
|
||||
@staticmethod
|
||||
def _optional_uuid(value: UUID | str | None) -> UUID | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, UUID):
|
||||
return value
|
||||
return UUID(str(value))
|
||||
|
||||
def create(self, data: TrainCreate) -> Train:
|
||||
train = Train(
|
||||
designation=data.designation,
|
||||
operator_id=self._optional_uuid(data.operator_id),
|
||||
home_station_id=self._optional_uuid(data.home_station_id),
|
||||
capacity=data.capacity,
|
||||
max_speed_kph=data.max_speed_kph,
|
||||
consist=data.consist,
|
||||
)
|
||||
self.session.add(train)
|
||||
return train
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
59
backend/app/services/auth.py
Normal file
59
backend/app/services/auth.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Optional
|
||||
|
||||
from backend.app.core.security import (
|
||||
create_access_token,
|
||||
get_password_hash,
|
||||
verify_password,
|
||||
)
|
||||
from backend.app.models import AuthResponse, UserInDB, UserPublic
|
||||
|
||||
_DEMO_USER = UserInDB(
|
||||
username="demo",
|
||||
full_name="Demo Engineer",
|
||||
hashed_password=get_password_hash("railgame123"),
|
||||
)
|
||||
|
||||
_FAKE_USERS: Dict[str, UserInDB] = {_DEMO_USER.username: _DEMO_USER}
|
||||
|
||||
|
||||
def get_user(username: str) -> Optional[UserInDB]:
|
||||
return _FAKE_USERS.get(username)
|
||||
|
||||
|
||||
def authenticate_user(username: str, password: str) -> Optional[UserInDB]:
|
||||
user = get_user(username)
|
||||
if not user:
|
||||
return None
|
||||
if not verify_password(password, user.hashed_password):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def issue_token_for_user(user: UserInDB) -> AuthResponse:
|
||||
return AuthResponse(
|
||||
access_token=create_access_token(subject=user.username),
|
||||
token_type="bearer",
|
||||
user=to_public_user(user),
|
||||
)
|
||||
|
||||
|
||||
def to_public_user(user: UserInDB) -> UserPublic:
|
||||
return UserPublic(username=user.username, full_name=user.full_name)
|
||||
|
||||
|
||||
def register_user(username: str, password: str, full_name: Optional[str] = None) -> UserInDB:
|
||||
normalized_username = username.strip()
|
||||
if not normalized_username:
|
||||
raise ValueError("Username must not be empty")
|
||||
if normalized_username in _FAKE_USERS:
|
||||
raise ValueError("Username already exists")
|
||||
|
||||
user = UserInDB(
|
||||
username=normalized_username,
|
||||
full_name=full_name.strip() if full_name else None,
|
||||
hashed_password=get_password_hash(password),
|
||||
)
|
||||
_FAKE_USERS[normalized_username] = user
|
||||
return user
|
||||
157
backend/app/services/network.py
Normal file
157
backend/app/services/network.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Domain services for railway network aggregation."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Iterable, cast
|
||||
|
||||
from geoalchemy2.elements import WKBElement, WKTElement
|
||||
from geoalchemy2.shape import to_shape
|
||||
try: # pragma: no cover - optional dependency guard
|
||||
from shapely.geometry import Point # type: ignore
|
||||
except ImportError: # pragma: no cover - allow running without shapely at import time
|
||||
Point = None # type: ignore[assignment]
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.app.models import StationModel, TrackModel, TrainModel
|
||||
from backend.app.repositories import StationRepository, TrackRepository, TrainRepository
|
||||
|
||||
|
||||
def _timestamp() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _fallback_snapshot() -> dict[str, list[dict[str, object]]]:
|
||||
now = _timestamp()
|
||||
stations = [
|
||||
StationModel(
|
||||
id="station-1",
|
||||
name="Central",
|
||||
latitude=52.520008,
|
||||
longitude=13.404954,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
),
|
||||
StationModel(
|
||||
id="station-2",
|
||||
name="Harbor",
|
||||
latitude=53.551086,
|
||||
longitude=9.993682,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
),
|
||||
]
|
||||
|
||||
tracks = [
|
||||
TrackModel(
|
||||
id="track-1",
|
||||
start_station_id="station-1",
|
||||
end_station_id="station-2",
|
||||
length_meters=289000.0,
|
||||
max_speed_kph=230.0,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
]
|
||||
|
||||
trains = [
|
||||
TrainModel(
|
||||
id="train-1",
|
||||
designation="ICE 123",
|
||||
capacity=400,
|
||||
max_speed_kph=300.0,
|
||||
operating_track_ids=[track.id for track in tracks],
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
]
|
||||
|
||||
return _serialize_snapshot(stations, tracks, trains)
|
||||
|
||||
|
||||
def _serialize_snapshot(
|
||||
stations: Iterable[StationModel],
|
||||
tracks: Iterable[TrackModel],
|
||||
trains: Iterable[TrainModel],
|
||||
) -> dict[str, list[dict[str, object]]]:
|
||||
return {
|
||||
"stations": [station.model_dump(by_alias=True) for station in stations],
|
||||
"tracks": [track.model_dump(by_alias=True) for track in tracks],
|
||||
"trains": [train.model_dump(by_alias=True) for train in trains],
|
||||
}
|
||||
|
||||
|
||||
def _to_float(value: Decimal | float | int | None, default: float = 0.0) -> float:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
return float(value)
|
||||
|
||||
|
||||
def get_network_snapshot(session: Session) -> dict[str, list[dict[str, object]]]:
|
||||
station_repo = StationRepository(session)
|
||||
track_repo = TrackRepository(session)
|
||||
train_repo = TrainRepository(session)
|
||||
|
||||
stations_entities = station_repo.list_active()
|
||||
tracks_entities = track_repo.list_all()
|
||||
trains_entities = train_repo.list_all()
|
||||
|
||||
if not stations_entities and not tracks_entities and not trains_entities:
|
||||
return _fallback_snapshot()
|
||||
|
||||
station_models: list[StationModel] = []
|
||||
for station in stations_entities:
|
||||
location = station.location
|
||||
geom = (
|
||||
to_shape(cast(WKBElement | WKTElement, location))
|
||||
if location is not None and Point is not None
|
||||
else None
|
||||
)
|
||||
if Point is not None and geom is not None and isinstance(geom, Point):
|
||||
latitude = float(geom.y)
|
||||
longitude = float(geom.x)
|
||||
else:
|
||||
latitude = 0.0
|
||||
longitude = 0.0
|
||||
station_models.append(
|
||||
StationModel(
|
||||
id=str(station.id),
|
||||
name=station.name,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
created_at=cast(datetime, station.created_at),
|
||||
updated_at=cast(datetime, station.updated_at),
|
||||
)
|
||||
)
|
||||
|
||||
track_models: list[TrackModel] = []
|
||||
for track in tracks_entities:
|
||||
track_models.append(
|
||||
TrackModel(
|
||||
id=str(track.id),
|
||||
start_station_id=str(track.start_station_id),
|
||||
end_station_id=str(track.end_station_id),
|
||||
length_meters=_to_float(track.length_meters),
|
||||
max_speed_kph=_to_float(track.max_speed_kph),
|
||||
created_at=cast(datetime, track.created_at),
|
||||
updated_at=cast(datetime, track.updated_at),
|
||||
)
|
||||
)
|
||||
|
||||
train_models: list[TrainModel] = []
|
||||
for train in trains_entities:
|
||||
train_models.append(
|
||||
TrainModel(
|
||||
id=str(train.id),
|
||||
designation=train.designation,
|
||||
capacity=train.capacity,
|
||||
max_speed_kph=_to_float(train.max_speed_kph),
|
||||
operating_track_ids=[],
|
||||
created_at=cast(datetime, train.created_at),
|
||||
updated_at=cast(datetime, train.updated_at),
|
||||
)
|
||||
)
|
||||
|
||||
return _serialize_snapshot(station_models, track_models, train_models)
|
||||
0
backend/app/websocket/__init__.py
Normal file
0
backend/app/websocket/__init__.py
Normal file
69
backend/migrations/env.py
Normal file
69
backend/migrations/env.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from logging.config import fileConfig
|
||||
from typing import Any, Dict
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from backend.app.core.config import get_settings
|
||||
from backend.app.db.models import Base
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
# target_metadata = None
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def get_run_options() -> Dict[str, Any]:
|
||||
settings = get_settings()
|
||||
return {"url": settings.database_url}
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode."""
|
||||
|
||||
kwargs = get_run_options()
|
||||
context.configure(
|
||||
url=kwargs["url"],
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
compare_type=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
|
||||
kwargs = get_run_options()
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
url=kwargs["url"],
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
19
backend/migrations/script.py.mako
Normal file
19
backend/migrations/script.py.mako
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Template for new Alembic migration scripts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
108
backend/migrations/versions/20251011_01_initial_schema.py
Normal file
108
backend/migrations/versions/20251011_01_initial_schema.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Initial PostgreSQL/PostGIS schema"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from geoalchemy2.types import Geometry
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "20251011_01"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS postgis")
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto")
|
||||
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("username", sa.String(length=64), nullable=False, unique=True),
|
||||
sa.Column("email", sa.String(length=255), nullable=True, unique=True),
|
||||
sa.Column("full_name", sa.String(length=128), nullable=True),
|
||||
sa.Column("password_hash", sa.String(length=256), nullable=False),
|
||||
sa.Column("role", sa.String(length=32), nullable=False, server_default="player"),
|
||||
sa.Column("preferences", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('utc', now())"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('utc', now())"), nullable=False),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"stations",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("osm_id", sa.String(length=32), nullable=True),
|
||||
sa.Column("name", sa.String(length=128), nullable=False),
|
||||
sa.Column("code", sa.String(length=16), nullable=True),
|
||||
sa.Column("location", Geometry(geometry_type="POINT", srid=4326), nullable=False),
|
||||
sa.Column("elevation_m", sa.Float(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('utc', now())"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('utc', now())"), nullable=False),
|
||||
)
|
||||
op.create_index("ix_stations_location", "stations", ["location"], postgresql_using="gist")
|
||||
|
||||
op.create_table(
|
||||
"tracks",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("name", sa.String(length=128), nullable=True),
|
||||
sa.Column("start_station_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("end_station_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("length_meters", sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column("max_speed_kph", sa.Integer(), nullable=True),
|
||||
sa.Column("is_bidirectional", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="planned"),
|
||||
sa.Column("track_geometry", Geometry(geometry_type="LINESTRING", srid=4326), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('utc', now())"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('utc', now())"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["start_station_id"], ["stations.id"], ondelete="RESTRICT"),
|
||||
sa.ForeignKeyConstraint(["end_station_id"], ["stations.id"], ondelete="RESTRICT"),
|
||||
sa.UniqueConstraint("start_station_id", "end_station_id", name="uq_tracks_station_pair"),
|
||||
)
|
||||
op.create_index("ix_tracks_geometry", "tracks", ["track_geometry"], postgresql_using="gist")
|
||||
|
||||
op.create_table(
|
||||
"trains",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("designation", sa.String(length=64), nullable=False, unique=True),
|
||||
sa.Column("operator_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("home_station_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("capacity", sa.Integer(), nullable=False),
|
||||
sa.Column("max_speed_kph", sa.Integer(), nullable=False),
|
||||
sa.Column("consist", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('utc', now())"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('utc', now())"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["operator_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["home_station_id"], ["stations.id"], ondelete="SET NULL"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"train_schedules",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("train_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("sequence_index", sa.Integer(), nullable=False),
|
||||
sa.Column("station_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("scheduled_arrival", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("scheduled_departure", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("dwell_seconds", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('utc', now())"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("timezone('utc', now())"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["train_id"], ["trains.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["station_id"], ["stations.id"], ondelete="CASCADE"),
|
||||
sa.UniqueConstraint("train_id", "sequence_index", name="uq_train_schedule_sequence"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("train_schedules")
|
||||
op.drop_table("trains")
|
||||
op.drop_index("ix_tracks_geometry", table_name="tracks")
|
||||
op.drop_table("tracks")
|
||||
op.drop_index("ix_stations_location", table_name="stations")
|
||||
op.drop_table("stations")
|
||||
op.drop_table("users")
|
||||
op.execute("DROP EXTENSION IF EXISTS pgcrypto")
|
||||
op.execute("DROP EXTENSION IF EXISTS postgis")
|
||||
10
backend/requirements/base.txt
Normal file
10
backend/requirements/base.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
fastapi>=0.111.0,<1.0.0
|
||||
uvicorn[standard]>=0.24.0,<1.0.0
|
||||
pydantic-settings>=2.0.0,<3.0.0
|
||||
python-jose[cryptography]>=3.3.0,<4.0.0
|
||||
passlib[bcrypt]>=1.7.4,<2.0.0
|
||||
SQLAlchemy>=2.0.31,<3.0.0
|
||||
alembic>=1.13.1,<2.0.0
|
||||
geoalchemy2>=0.15.2,<0.16.0
|
||||
psycopg[binary]>=3.1.19,<4.0.0
|
||||
shapely>=2.0.4,<3.0.0
|
||||
4
backend/requirements/dev.txt
Normal file
4
backend/requirements/dev.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
-r base.txt
|
||||
|
||||
httpx>=0.27.0,<0.28.0
|
||||
pytest>=8.0.0,<9.0.0
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
81
backend/tests/test_auth_api.py
Normal file
81
backend/tests/test_auth_api.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_login_returns_token_and_user() -> None:
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "demo", "password": "railgame123"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
payload = response.json()
|
||||
assert "accessToken" in payload
|
||||
assert payload["tokenType"] == "bearer"
|
||||
assert payload["user"]["username"] == "demo"
|
||||
|
||||
|
||||
def test_login_with_invalid_credentials_fails() -> None:
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "demo", "password": "wrong"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_me_endpoint_returns_current_user() -> None:
|
||||
login = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "demo", "password": "railgame123"},
|
||||
)
|
||||
token = login.json()["accessToken"]
|
||||
|
||||
response = client.get(
|
||||
"/api/auth/me", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["username"] == "demo"
|
||||
|
||||
|
||||
def test_register_creates_user_and_returns_token() -> None:
|
||||
username = f"player_{uuid4().hex[:8]}"
|
||||
response = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"password": "testpass123",
|
||||
"fullName": "Test Player",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
payload = response.json()
|
||||
assert payload["user"]["username"] == username
|
||||
assert payload["tokenType"] == "bearer"
|
||||
|
||||
me = client.get(
|
||||
"/api/auth/me",
|
||||
headers={"Authorization": f"Bearer {payload['accessToken']}"},
|
||||
)
|
||||
assert me.status_code == 200
|
||||
assert me.json()["username"] == username
|
||||
|
||||
|
||||
def test_register_duplicate_username_returns_conflict() -> None:
|
||||
username = f"dupe_{uuid4().hex[:8]}"
|
||||
first = client.post(
|
||||
"/api/auth/register",
|
||||
json={"username": username, "password": "firstpass"},
|
||||
)
|
||||
assert first.status_code == 201
|
||||
|
||||
duplicate = client.post(
|
||||
"/api/auth/register",
|
||||
json={"username": username, "password": "secondpass"},
|
||||
)
|
||||
assert duplicate.status_code == 409
|
||||
assert duplicate.json()["detail"] == "Username already exists"
|
||||
11
backend/tests/test_health.py
Normal file
11
backend/tests/test_health.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_health_check() -> None:
|
||||
response = client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
51
backend/tests/test_models.py
Normal file
51
backend/tests/test_models.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from backend.app.models import StationModel, TrackModel, TrainModel
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def test_station_model_round_trip() -> None:
|
||||
timestamp = _now()
|
||||
station = StationModel(
|
||||
id="station-1",
|
||||
name="Central",
|
||||
latitude=52.52,
|
||||
longitude=13.405,
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
assert station.name == "Central"
|
||||
assert station.model_dump()["id"] == "station-1"
|
||||
|
||||
|
||||
def test_track_model_properties() -> None:
|
||||
timestamp = _now()
|
||||
track = TrackModel(
|
||||
id="track-1",
|
||||
start_station_id="station-1",
|
||||
end_station_id="station-2",
|
||||
length_meters=1500.0,
|
||||
max_speed_kph=120.0,
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
assert track.length_meters > 0
|
||||
assert track.start_station_id != track.end_station_id
|
||||
|
||||
|
||||
def test_train_model_operating_tracks() -> None:
|
||||
timestamp = _now()
|
||||
train = TrainModel(
|
||||
id="train-1",
|
||||
designation="Express",
|
||||
capacity=350,
|
||||
max_speed_kph=200.0,
|
||||
operating_track_ids=["track-1", "track-2"],
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
assert train.capacity >= 0
|
||||
assert len(train.operating_track_ids) == 2
|
||||
35
backend/tests/test_network_api.py
Normal file
35
backend/tests/test_network_api.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app.main import app
|
||||
|
||||
AUTH_CREDENTIALS = {"username": "demo", "password": "railgame123"}
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def _authenticate() -> str:
|
||||
response = client.post("/api/auth/login", json=AUTH_CREDENTIALS)
|
||||
assert response.status_code == 200
|
||||
return response.json()["accessToken"]
|
||||
|
||||
|
||||
def test_network_snapshot_requires_authentication() -> None:
|
||||
response = client.get("/api/network")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_network_snapshot_endpoint_returns_collections() -> None:
|
||||
token = _authenticate()
|
||||
response = client.get(
|
||||
"/api/network", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
payload = response.json()
|
||||
assert set(payload.keys()) == {"stations", "tracks", "trains"}
|
||||
assert all(isinstance(payload[key], list) for key in payload)
|
||||
assert payload["stations"], "Expected sample station data"
|
||||
assert payload["trains"], "Expected sample train data"
|
||||
|
||||
station = payload["stations"][0]
|
||||
assert "name" in station and "createdAt" in station
|
||||
70
backend/tests/test_network_service.py
Normal file
70
backend/tests/test_network_service.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.repositories import StationRepository, TrackRepository, TrainRepository
|
||||
from backend.app.services.network import get_network_snapshot
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_entities() -> dict[str, SimpleNamespace]:
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
station = SimpleNamespace(
|
||||
id=uuid4(),
|
||||
name="Test Station",
|
||||
location=None,
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
track = SimpleNamespace(
|
||||
id=uuid4(),
|
||||
start_station_id=station.id,
|
||||
end_station_id=station.id,
|
||||
length_meters=1234.5,
|
||||
max_speed_kph=160,
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
train = SimpleNamespace(
|
||||
id=uuid4(),
|
||||
designation="Test Express",
|
||||
capacity=200,
|
||||
max_speed_kph=220,
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
return {"station": station, "track": track, "train": train}
|
||||
|
||||
|
||||
def test_network_snapshot_prefers_repository_data(monkeypatch: pytest.MonkeyPatch, sample_entities: dict[str, SimpleNamespace]) -> None:
|
||||
station = sample_entities["station"]
|
||||
track = sample_entities["track"]
|
||||
train = sample_entities["train"]
|
||||
|
||||
monkeypatch.setattr(StationRepository, "list_active", lambda self: [station])
|
||||
monkeypatch.setattr(TrackRepository, "list_all", lambda self: [track])
|
||||
monkeypatch.setattr(TrainRepository, "list_all", lambda self: [train])
|
||||
|
||||
snapshot = get_network_snapshot(session=None) # type: ignore[arg-type]
|
||||
|
||||
assert snapshot["stations"]
|
||||
assert snapshot["stations"][0]["name"] == station.name
|
||||
assert snapshot["tracks"][0]["lengthMeters"] == pytest.approx(track.length_meters)
|
||||
assert snapshot["trains"][0]["designation"] == train.designation
|
||||
assert snapshot["trains"][0]["operatingTrackIds"] == []
|
||||
|
||||
|
||||
def test_network_snapshot_falls_back_when_repositories_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(StationRepository, "list_active", lambda self: [])
|
||||
monkeypatch.setattr(TrackRepository, "list_all", lambda self: [])
|
||||
monkeypatch.setattr(TrainRepository, "list_all", lambda self: [])
|
||||
|
||||
snapshot = get_network_snapshot(session=None) # type: ignore[arg-type]
|
||||
|
||||
assert snapshot["stations"]
|
||||
assert snapshot["trains"]
|
||||
assert any(station["name"] == "Central" for station in snapshot["stations"])
|
||||
106
backend/tests/test_repositories.py
Normal file
106
backend/tests/test_repositories.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, List
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.models import StationCreate, TrackCreate, TrainCreate
|
||||
from backend.app.repositories import StationRepository, TrackRepository, TrainRepository
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummySession:
|
||||
added: List[Any] = field(default_factory=list)
|
||||
|
||||
def add(self, instance: Any) -> None:
|
||||
self.added.append(instance)
|
||||
|
||||
def add_all(self, instances: list[Any]) -> None:
|
||||
self.added.extend(instances)
|
||||
|
||||
def scalars(self, _statement: Any) -> list[Any]: # pragma: no cover - not used here
|
||||
return []
|
||||
|
||||
def flush(self, _objects: list[Any] | None = None) -> None: # pragma: no cover - optional
|
||||
return None
|
||||
|
||||
|
||||
def test_station_repository_create_generates_geometry() -> None:
|
||||
session = DummySession()
|
||||
repo = StationRepository(session) # type: ignore[arg-type]
|
||||
|
||||
station = repo.create(
|
||||
StationCreate(
|
||||
name="Central",
|
||||
latitude=52.52,
|
||||
longitude=13.405,
|
||||
osm_id="123",
|
||||
code="BER",
|
||||
elevation_m=34.5,
|
||||
)
|
||||
)
|
||||
|
||||
assert station.name == "Central"
|
||||
assert session.added and session.added[0] is station
|
||||
assert getattr(station.location, "srid", None) == 4326
|
||||
assert "POINT" in str(station.location)
|
||||
|
||||
|
||||
def test_track_repository_requires_geometry() -> None:
|
||||
session = DummySession()
|
||||
repo = TrackRepository(session) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
repo.create(
|
||||
TrackCreate(
|
||||
start_station_id="00000000-0000-0000-0000-000000000001",
|
||||
end_station_id="00000000-0000-0000-0000-000000000002",
|
||||
coordinates=[(52.0, 13.0)],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_track_repository_create_builds_linestring() -> None:
|
||||
session = DummySession()
|
||||
repo = TrackRepository(session) # type: ignore[arg-type]
|
||||
|
||||
track = repo.create(
|
||||
TrackCreate(
|
||||
name="Main Line",
|
||||
start_station_id="00000000-0000-0000-0000-000000000001",
|
||||
end_station_id="00000000-0000-0000-0000-000000000002",
|
||||
coordinates=[(52.0, 13.0), (53.0, 14.0)],
|
||||
length_meters=1000.5,
|
||||
max_speed_kph=160,
|
||||
is_bidirectional=False,
|
||||
status="operational",
|
||||
)
|
||||
)
|
||||
|
||||
assert session.added and session.added[0] is track
|
||||
assert track.status == "operational"
|
||||
geom_repr = str(track.track_geometry)
|
||||
assert "LINESTRING" in geom_repr
|
||||
assert "13.0 52.0" in geom_repr
|
||||
|
||||
|
||||
def test_train_repository_create_supports_optional_ids() -> None:
|
||||
session = DummySession()
|
||||
repo = TrainRepository(session) # type: ignore[arg-type]
|
||||
|
||||
train = repo.create(
|
||||
TrainCreate(
|
||||
designation="ICE 123",
|
||||
capacity=400,
|
||||
max_speed_kph=300,
|
||||
operator_id=None,
|
||||
home_station_id="00000000-0000-0000-0000-000000000001",
|
||||
consist="locomotive+cars",
|
||||
)
|
||||
)
|
||||
|
||||
assert session.added and session.added[0] is train
|
||||
assert train.designation == "ICE 123"
|
||||
assert str(train.home_station_id).endswith("1")
|
||||
assert train.operator_id is None
|
||||
Reference in New Issue
Block a user