ch14
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# api_code/.env
|
||||
SECRET_KEY="ec604d5610ac4668a44418711be8251f"
|
||||
DEBUG=false
|
||||
API_VERSION=1.0.0
|
||||
@@ -0,0 +1 @@
|
||||
# api_code/api/__init__.py
|
||||
@@ -0,0 +1,42 @@
|
||||
# api_code/api/admin.py
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
Header,
|
||||
HTTPException,
|
||||
Response,
|
||||
status,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import crud
|
||||
from .deps import Settings, get_db, get_settings
|
||||
from .util import is_admin
|
||||
|
||||
router = APIRouter(prefix="/admin")
|
||||
|
||||
|
||||
def ensure_admin(settings: Settings, authorization: str):
|
||||
if not is_admin(
|
||||
settings=settings, authorization=authorization
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"You must be an admin to access this endpoint.",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/stations/{station_id}", tags=["Admin"])
|
||||
def admin_delete_station(
|
||||
station_id: int,
|
||||
authorization: Optional[str] = Header(None),
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
ensure_admin(settings, authorization)
|
||||
row_count = crud.delete_station(db=db, station_id=station_id)
|
||||
if row_count:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
return Response(status_code=status.HTTP_404_NOT_FOUND)
|
||||
@@ -0,0 +1,11 @@
|
||||
# api_code/api/config.py
|
||||
from pydantic import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
secret_key: str
|
||||
debug: bool
|
||||
api_version: str
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
@@ -0,0 +1,231 @@
|
||||
# api_code/api/crud.py
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import delete, update
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from . import models, schemas
|
||||
|
||||
# USERS
|
||||
|
||||
|
||||
def get_users(db: Session, email: str = None):
|
||||
q = db.query(models.User)
|
||||
if email is not None:
|
||||
q = q.filter(models.User.email.ilike(f"%{email}%"))
|
||||
return q.all()
|
||||
|
||||
|
||||
def get_user(db: Session, user_id: int):
|
||||
return (
|
||||
db.query(models.User)
|
||||
.filter(models.User.id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def get_user_by_email(db: Session, email: str):
|
||||
return (
|
||||
db.query(models.User)
|
||||
.filter(models.User.email.ilike(email))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def create_user(
|
||||
db: Session, user: schemas.UserCreate, user_id: int = None
|
||||
):
|
||||
hashed_password = models.User.hash_password(user.password)
|
||||
user_dict = {
|
||||
**user.dict(exclude_unset=True),
|
||||
"password": hashed_password,
|
||||
}
|
||||
if user_id is not None:
|
||||
user_dict.update({"id": user_id})
|
||||
db_user = models.User(**user_dict)
|
||||
db.add(db_user)
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
return db_user
|
||||
|
||||
|
||||
def update_user(
|
||||
db: Session, user: schemas.UserUpdate, user_id: int
|
||||
):
|
||||
user_dict = {
|
||||
**user.dict(exclude_unset=True),
|
||||
}
|
||||
if user.password is not None:
|
||||
user_dict.update(
|
||||
{"password": models.User.hash_password(user.password)}
|
||||
)
|
||||
stm = (
|
||||
update(models.User)
|
||||
.where(models.User.id == user_id)
|
||||
.values(user_dict)
|
||||
)
|
||||
result = db.execute(stm)
|
||||
db.commit()
|
||||
return result.rowcount
|
||||
|
||||
|
||||
def delete_user(db: Session, user_id: int):
|
||||
stm = delete(models.User).where(models.User.id == user_id)
|
||||
result = db.execute(stm)
|
||||
db.commit()
|
||||
return result.rowcount
|
||||
|
||||
|
||||
# STATIONS
|
||||
|
||||
|
||||
def get_stations(db: Session, code: str = None):
|
||||
q = db.query(models.Station)
|
||||
if code is not None:
|
||||
q = q.filter(models.Station.code.ilike(code))
|
||||
return q.all()
|
||||
|
||||
|
||||
def get_station(db: Session, station_id: int):
|
||||
return (
|
||||
db.query(models.Station)
|
||||
.filter(models.Station.id == station_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def get_station_by_code(db: Session, code: str):
|
||||
return (
|
||||
db.query(models.Station)
|
||||
.filter(models.Station.code.ilike(code))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def create_station(
|
||||
db: Session,
|
||||
station: schemas.StationCreate,
|
||||
):
|
||||
db_station = models.Station(**station.dict())
|
||||
db.add(db_station)
|
||||
db.commit()
|
||||
db.refresh(db_station)
|
||||
return db_station
|
||||
|
||||
|
||||
def update_station(
|
||||
db: Session, station: schemas.StationUpdate, station_id: int
|
||||
):
|
||||
stm = (
|
||||
update(models.Station)
|
||||
.where(models.Station.id == station_id)
|
||||
.values(station.dict(exclude_unset=True))
|
||||
)
|
||||
result = db.execute(stm)
|
||||
db.commit()
|
||||
return result.rowcount
|
||||
|
||||
|
||||
def delete_station(db: Session, station_id: int):
|
||||
stm = delete(models.Station).where(
|
||||
models.Station.id == station_id
|
||||
)
|
||||
result = db.execute(stm)
|
||||
db.commit()
|
||||
return result.rowcount
|
||||
|
||||
|
||||
# TRAINS
|
||||
|
||||
|
||||
def get_trains(
|
||||
db: Session,
|
||||
station_from_code: str = None,
|
||||
station_to_code: str = None,
|
||||
include_all: bool = False,
|
||||
):
|
||||
q = db.query(models.Train)
|
||||
|
||||
if station_from_code is not None:
|
||||
st_from = aliased(models.Station)
|
||||
q = q.join(st_from, models.Train.station_from)
|
||||
q = q.filter(st_from.code.ilike(f"%{station_from_code}%"))
|
||||
|
||||
if station_to_code is not None:
|
||||
st_to = aliased(models.Station)
|
||||
q = q.join(st_to, models.Train.station_to)
|
||||
q = q.filter(st_to.code.ilike(f"%{station_to_code}%"))
|
||||
|
||||
if not include_all:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
q = q.filter(models.Train.departs_at > now)
|
||||
|
||||
return q.all()
|
||||
|
||||
|
||||
def get_train(db: Session, train_id: int):
|
||||
return (
|
||||
db.query(models.Train)
|
||||
.filter(models.Train.id == train_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def get_train_by_name(db: Session, name: str):
|
||||
return (
|
||||
db.query(models.Train)
|
||||
.filter(models.Train.name == name)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def create_train(db: Session, train: schemas.TrainCreate):
|
||||
train_dict = train.dict(exclude_unset=True)
|
||||
db_train = models.Train(**train_dict)
|
||||
db.add(db_train)
|
||||
db.commit()
|
||||
db.refresh(db_train)
|
||||
return db_train
|
||||
|
||||
|
||||
def delete_train(db: Session, train_id: int):
|
||||
stm = delete(models.Train).where(models.Train.id == train_id)
|
||||
result = db.execute(stm)
|
||||
db.commit()
|
||||
return result.rowcount
|
||||
|
||||
|
||||
# TICKETS
|
||||
|
||||
|
||||
def get_tickets(db: Session):
|
||||
return db.query(models.Ticket).all()
|
||||
|
||||
|
||||
def get_ticket(db: Session, ticket_id: int):
|
||||
return (
|
||||
db.query(models.Ticket)
|
||||
.filter(models.Ticket.id == ticket_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def create_ticket(db: Session, ticket: schemas.TicketCreate):
|
||||
ticket_dict = ticket.dict(exclude_unset=True)
|
||||
ticket_dict.update(
|
||||
{"created_at": datetime.now(tz=timezone.utc)}
|
||||
)
|
||||
db_ticket = models.Ticket(**ticket_dict)
|
||||
db.add(db_ticket)
|
||||
db.commit()
|
||||
db.refresh(db_ticket)
|
||||
return db_ticket
|
||||
|
||||
|
||||
def delete_ticket(db: Session, ticket_id: int):
|
||||
stm = delete(models.Ticket).where(
|
||||
models.Ticket.id == ticket_id
|
||||
)
|
||||
result = db.execute(stm)
|
||||
db.commit()
|
||||
return result.rowcount
|
||||
@@ -0,0 +1,24 @@
|
||||
# api_code/api/database.py
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from .config import Settings
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
DB_URL = "sqlite:///train.db"
|
||||
|
||||
|
||||
engine = create_engine(
|
||||
DB_URL,
|
||||
connect_args={"check_same_thread": False},
|
||||
echo=settings.debug, # when debug is True, queries are logged
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(
|
||||
autocommit=False, autoflush=False, bind=engine
|
||||
)
|
||||
|
||||
Base = declarative_base()
|
||||
@@ -0,0 +1,20 @@
|
||||
# api_code/api/deps.py
|
||||
from functools import lru_cache
|
||||
|
||||
from .config import Settings
|
||||
from .database import SessionLocal
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Return a DB Session."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings():
|
||||
"""Return the app settings."""
|
||||
return Settings()
|
||||
@@ -0,0 +1,181 @@
|
||||
# api_code/api/models.py
|
||||
import enum
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
DateTime,
|
||||
Enum,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
Unicode,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from .database import Base
|
||||
|
||||
UNICODE_LEN = 128
|
||||
SALT_LEN = 64
|
||||
|
||||
# Enums
|
||||
|
||||
|
||||
class Classes(str, enum.Enum):
|
||||
first = "first"
|
||||
second = "second"
|
||||
|
||||
|
||||
class Roles(str, enum.Enum):
|
||||
admin = "admin"
|
||||
passenger = "passenger"
|
||||
|
||||
|
||||
# Models
|
||||
|
||||
|
||||
class Station(Base):
|
||||
__tablename__ = "station"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
code = Column(
|
||||
Unicode(UNICODE_LEN), nullable=False, unique=True
|
||||
)
|
||||
country = Column(Unicode(UNICODE_LEN), nullable=False)
|
||||
city = Column(Unicode(UNICODE_LEN), nullable=False)
|
||||
|
||||
departures = relationship(
|
||||
"Train",
|
||||
foreign_keys="[Train.station_from_id]",
|
||||
back_populates="station_from",
|
||||
)
|
||||
arrivals = relationship(
|
||||
"Train",
|
||||
foreign_keys="[Train.station_to_id]",
|
||||
back_populates="station_to",
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.code}: id={self.id} city={self.city}>"
|
||||
|
||||
__str__ = __repr__
|
||||
|
||||
|
||||
class Train(Base):
|
||||
__tablename__ = "train"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(Unicode(UNICODE_LEN), nullable=False)
|
||||
|
||||
station_from_id = Column(
|
||||
ForeignKey("station.id"), nullable=False
|
||||
)
|
||||
station_from = relationship(
|
||||
"Station",
|
||||
foreign_keys=[station_from_id],
|
||||
back_populates="departures",
|
||||
)
|
||||
|
||||
station_to_id = Column(
|
||||
ForeignKey("station.id"), nullable=False
|
||||
)
|
||||
station_to = relationship(
|
||||
"Station",
|
||||
foreign_keys=[station_to_id],
|
||||
back_populates="arrivals",
|
||||
)
|
||||
|
||||
departs_at = Column(DateTime(timezone=True), nullable=False)
|
||||
arrives_at = Column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
first_class = Column(Integer, default=0, nullable=False)
|
||||
second_class = Column(Integer, default=0, nullable=False)
|
||||
seats_per_car = Column(Integer, default=0, nullable=False)
|
||||
|
||||
tickets = relationship("Ticket", back_populates="train")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.name}: id={self.id}>"
|
||||
|
||||
__str__ = __repr__
|
||||
|
||||
|
||||
class Ticket(Base):
|
||||
__tablename__ = "ticket"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False)
|
||||
user_id = Column(ForeignKey("user.id"), nullable=False)
|
||||
user = relationship(
|
||||
"User", foreign_keys=[user_id], back_populates="tickets"
|
||||
)
|
||||
|
||||
train_id = Column(ForeignKey("train.id"), nullable=False)
|
||||
train = relationship(
|
||||
"Train", foreign_keys=[train_id], back_populates="tickets"
|
||||
)
|
||||
|
||||
price = Column(Float, default=0, nullable=False)
|
||||
car_class = Column(Enum(Classes), nullable=False)
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<id={self.id} user={self.user} train={self.train}>"
|
||||
)
|
||||
|
||||
__str__ = __repr__
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "user"
|
||||
|
||||
pwd_separator = "#"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
full_name = Column(Unicode(UNICODE_LEN), nullable=False)
|
||||
email = Column(Unicode(256), nullable=False, unique=True)
|
||||
password = Column(Unicode(256), nullable=False)
|
||||
role = Column(Enum(Roles), nullable=False)
|
||||
|
||||
tickets = relationship("Ticket", back_populates="user")
|
||||
|
||||
def is_valid_password(self, password: str):
|
||||
"""Tell if password matches the one stored in DB."""
|
||||
salt, stored_hash = self.password.split(
|
||||
self.pwd_separator
|
||||
)
|
||||
_, computed_hash = _hash(
|
||||
password=password, salt=bytes.fromhex(salt)
|
||||
)
|
||||
return secrets.compare_digest(stored_hash, computed_hash)
|
||||
|
||||
@classmethod
|
||||
def hash_password(cls, password: str, salt: bytes = None):
|
||||
salt, hashed = _hash(password=password, salt=salt)
|
||||
return f"{salt}{cls.pwd_separator}{hashed}"
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<{self.full_name}: id={self.id} "
|
||||
f"role={self.role.name}>"
|
||||
)
|
||||
|
||||
__str__ = __repr__
|
||||
|
||||
|
||||
def _hash(password: str, salt: bytes = None):
|
||||
if salt is None:
|
||||
salt = os.urandom(SALT_LEN)
|
||||
iterations = 100 # should be at least 100k for SHA-256
|
||||
|
||||
hashed = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt,
|
||||
iterations,
|
||||
dklen=128,
|
||||
)
|
||||
|
||||
return salt.hex(), hashed.hex()
|
||||
@@ -0,0 +1,118 @@
|
||||
# api_code/api/schemas.py
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import models
|
||||
|
||||
# USERS
|
||||
|
||||
|
||||
class Auth(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class AuthToken(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
full_name: str
|
||||
email: str
|
||||
role: models.Roles
|
||||
|
||||
|
||||
class User(UserBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
use_enum_values = True
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str
|
||||
|
||||
|
||||
class UserUpdate(UserBase):
|
||||
full_name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
role: Optional[models.Roles] = None
|
||||
|
||||
|
||||
# STATIONS
|
||||
|
||||
|
||||
class StationBase(BaseModel):
|
||||
code: str
|
||||
country: str
|
||||
city: str
|
||||
|
||||
|
||||
class Station(StationBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class StationCreate(StationBase):
|
||||
pass
|
||||
|
||||
|
||||
class StationUpdate(StationBase):
|
||||
code: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
|
||||
|
||||
# TRAINS
|
||||
|
||||
|
||||
class TrainBase(BaseModel):
|
||||
name: str
|
||||
station_from: Optional[Station] = None
|
||||
station_to: Optional[Station] = None
|
||||
departs_at: datetime
|
||||
arrives_at: datetime
|
||||
first_class: int
|
||||
second_class: int
|
||||
seats_per_car: int
|
||||
|
||||
|
||||
class Train(TrainBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class TrainCreate(TrainBase):
|
||||
station_from_id: int
|
||||
station_to_id: int
|
||||
|
||||
|
||||
# TICKETS
|
||||
|
||||
|
||||
class TicketBase(BaseModel):
|
||||
user_id: int
|
||||
train_id: int
|
||||
price: float
|
||||
car_class: models.Classes
|
||||
|
||||
|
||||
class Ticket(TicketBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
use_enum_values = True
|
||||
|
||||
|
||||
class TicketCreate(TicketBase):
|
||||
pass
|
||||
@@ -0,0 +1,122 @@
|
||||
# api_code/api/stations.py
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Response,
|
||||
status,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import crud
|
||||
from .deps import get_db
|
||||
from .schemas import Station, StationCreate, StationUpdate, Train
|
||||
|
||||
router = APIRouter(prefix="/stations")
|
||||
|
||||
|
||||
@router.get("", response_model=list[Station], tags=["Stations"])
|
||||
def get_stations(
|
||||
db: Session = Depends(get_db), code: Optional[str] = None
|
||||
):
|
||||
return crud.get_stations(db=db, code=code)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{station_id}", response_model=Station, tags=["Stations"]
|
||||
)
|
||||
def get_station(station_id: int, db: Session = Depends(get_db)):
|
||||
db_station = crud.get_station(db=db, station_id=station_id)
|
||||
if db_station is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Station {station_id} not found.",
|
||||
)
|
||||
return db_station
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{station_id}/departures",
|
||||
response_model=list[Train],
|
||||
tags=["Trains"],
|
||||
)
|
||||
def get_station_departures(
|
||||
station_id: int, db: Session = Depends(get_db)
|
||||
):
|
||||
db_station = _get_station(db=db, station_id=station_id)
|
||||
return db_station.departures
|
||||
|
||||
|
||||
def _get_station(db: Session, station_id: int):
|
||||
db_station = crud.get_station(db=db, station_id=station_id)
|
||||
if db_station is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Station {station_id} not found.",
|
||||
)
|
||||
return db_station
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{station_id}/arrivals",
|
||||
response_model=list[Train],
|
||||
tags=["Trains"],
|
||||
)
|
||||
def get_station_arrivals(
|
||||
station_id: int, db: Session = Depends(get_db)
|
||||
):
|
||||
db_station = _get_station(db=db, station_id=station_id)
|
||||
return db_station.arrivals
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=Station,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
tags=["Stations"],
|
||||
)
|
||||
def create_station(
|
||||
station: StationCreate, db: Session = Depends(get_db)
|
||||
):
|
||||
db_station = crud.get_station_by_code(
|
||||
db=db, code=station.code
|
||||
)
|
||||
if db_station:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Station {station.code} already exists.",
|
||||
)
|
||||
return crud.create_station(db=db, station=station)
|
||||
|
||||
|
||||
@router.put("/{station_id}", tags=["Stations"])
|
||||
def update_station(
|
||||
station_id: int,
|
||||
station: StationUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
db_station = crud.get_station(db=db, station_id=station_id)
|
||||
|
||||
if db_station is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Station {station_id} not found.",
|
||||
)
|
||||
|
||||
else:
|
||||
crud.update_station(
|
||||
db=db, station=station, station_id=station_id
|
||||
)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.delete("/{station_id}", tags=["Stations"])
|
||||
def delete_station(
|
||||
station_id: int, db: Session = Depends(get_db)
|
||||
):
|
||||
row_count = crud.delete_station(db=db, station_id=station_id)
|
||||
if row_count:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
return Response(status_code=status.HTTP_404_NOT_FOUND)
|
||||
@@ -0,0 +1,53 @@
|
||||
# api_code/api/tickets.py
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Response,
|
||||
status,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import crud
|
||||
from .deps import get_db
|
||||
from .schemas import Ticket, TicketCreate
|
||||
|
||||
router = APIRouter(prefix="/tickets")
|
||||
|
||||
|
||||
@router.get("", response_model=list[Ticket], tags=["Tickets"])
|
||||
def get_tickets(db: Session = Depends(get_db)):
|
||||
return crud.get_tickets(db=db)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{ticket_id}", response_model=Ticket, tags=["Tickets"]
|
||||
)
|
||||
def get_ticket(ticket_id: int, db: Session = Depends(get_db)):
|
||||
db_ticket = crud.get_ticket(db=db, ticket_id=ticket_id)
|
||||
if db_ticket is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Ticket {ticket_id} not found.",
|
||||
)
|
||||
return db_ticket
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=Ticket,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
tags=["Tickets"],
|
||||
)
|
||||
def create_ticket(
|
||||
ticket: TicketCreate, db: Session = Depends(get_db)
|
||||
):
|
||||
return crud.create_ticket(db=db, ticket=ticket)
|
||||
|
||||
|
||||
@router.delete("/{ticket_id}", tags=["Tickets"])
|
||||
def delete_ticket(ticket_id: int, db: Session = Depends(get_db)):
|
||||
row_count = crud.delete_ticket(db=db, ticket_id=ticket_id)
|
||||
if row_count:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
return Response(status_code=status.HTTP_404_NOT_FOUND)
|
||||
@@ -0,0 +1,84 @@
|
||||
# api_code/api/trains.py
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Response,
|
||||
status,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import crud
|
||||
from .deps import get_db
|
||||
from .schemas import Ticket, Train, TrainCreate
|
||||
|
||||
router = APIRouter(prefix="/trains")
|
||||
|
||||
|
||||
@router.get("", response_model=list[Train], tags=["Trains"])
|
||||
def get_trains(
|
||||
db: Session = Depends(get_db),
|
||||
station_from_code: str = None,
|
||||
station_to_code: str = None,
|
||||
include_all: Optional[bool] = False,
|
||||
):
|
||||
return crud.get_trains(
|
||||
db=db,
|
||||
station_from_code=station_from_code,
|
||||
station_to_code=station_to_code,
|
||||
include_all=include_all,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{train_id}", response_model=Train, tags=["Trains"])
|
||||
def get_train(train_id: int, db: Session = Depends(get_db)):
|
||||
db_train = crud.get_train(db=db, train_id=train_id)
|
||||
if db_train is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Train {train_id} not found."
|
||||
)
|
||||
return db_train
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{train_id}/tickets",
|
||||
response_model=list[Ticket],
|
||||
tags=["Tickets"],
|
||||
)
|
||||
def get_train_tickets(
|
||||
train_id: int, db: Session = Depends(get_db)
|
||||
):
|
||||
db_train = crud.get_train(db=db, train_id=train_id)
|
||||
if db_train is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Train {train_id} not found."
|
||||
)
|
||||
return db_train.tickets
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=Train,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
tags=["Trains"],
|
||||
)
|
||||
def create_train(
|
||||
train: TrainCreate, db: Session = Depends(get_db)
|
||||
):
|
||||
db_train = crud.get_train_by_name(db=db, name=train.name)
|
||||
if db_train:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Train {train.name} already exists.",
|
||||
)
|
||||
return crud.create_train(db=db, train=train)
|
||||
|
||||
|
||||
@router.delete("/{train_id}", tags=["Trains"])
|
||||
def delete_user(train_id: int, db: Session = Depends(get_db)):
|
||||
row_count = crud.delete_train(db=db, train_id=train_id)
|
||||
if row_count:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
return Response(status_code=status.HTTP_404_NOT_FOUND)
|
||||
@@ -0,0 +1,146 @@
|
||||
# api_code/api/users.py
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Response,
|
||||
status,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import crud
|
||||
from .deps import Settings, get_db, get_settings
|
||||
from .schemas import (
|
||||
Auth,
|
||||
AuthToken,
|
||||
Ticket,
|
||||
User,
|
||||
UserCreate,
|
||||
UserUpdate,
|
||||
)
|
||||
from .util import InvalidToken, create_token, extract_payload
|
||||
|
||||
router = APIRouter(prefix="/users")
|
||||
|
||||
|
||||
@router.get("", response_model=list[User], tags=["Users"])
|
||||
def get_users(
|
||||
db: Session = Depends(get_db), email: Optional[str] = None
|
||||
):
|
||||
return crud.get_users(db=db, email=email)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=User, tags=["Users"])
|
||||
def get_user(user_id: int, db: Session = Depends(get_db)):
|
||||
db_user = crud.get_user(db=db, user_id=user_id)
|
||||
if db_user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User {user_id} not found.",
|
||||
)
|
||||
return db_user
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{user_id}/tickets",
|
||||
response_model=list[Ticket],
|
||||
tags=["Users"],
|
||||
)
|
||||
def get_user_tickets(user_id: int, db: Session = Depends(get_db)):
|
||||
db_user = crud.get_user(db=db, user_id=user_id)
|
||||
if db_user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User {user_id} not found.",
|
||||
)
|
||||
return db_user.tickets
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=User,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
tags=["Users"],
|
||||
)
|
||||
def create_user(user: UserCreate, db: Session = Depends(get_db)):
|
||||
db_user = crud.get_user_by_email(db=db, email=user.email)
|
||||
if db_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"User {user.email} already exists.",
|
||||
)
|
||||
return crud.create_user(db=db, user=user)
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=User, tags=["Users"])
|
||||
def update_user(
|
||||
user_id: int, user: UserUpdate, db: Session = Depends(get_db)
|
||||
):
|
||||
db_user = crud.get_user(db=db, user_id=user_id)
|
||||
|
||||
if db_user is None:
|
||||
db_user = crud.get_user_by_email(db, user.email)
|
||||
|
||||
if db_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"User {user.email} already exists.",
|
||||
)
|
||||
|
||||
else:
|
||||
crud.create_user(db=db, user=user, user_id=user_id)
|
||||
return Response(status_code=status.HTTP_201_CREATED)
|
||||
|
||||
else:
|
||||
crud.update_user(db=db, user=user, user_id=user_id)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.delete("/{user_id}", tags=["Users"])
|
||||
def delete_user(user_id: int, db: Session = Depends(get_db)):
|
||||
row_count = crud.delete_user(db=db, user_id=user_id)
|
||||
if row_count:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
return Response(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
@router.post("/authenticate", tags=["Auth"])
|
||||
def authenticate(
|
||||
auth: Auth,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
db_user = crud.get_user_by_email(db=db, email=auth.email)
|
||||
if db_user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User {auth.email} not found.",
|
||||
)
|
||||
|
||||
if not db_user.is_valid_password(auth.password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Wrong username/password.",
|
||||
)
|
||||
|
||||
payload = {
|
||||
"email": auth.email,
|
||||
"role": db_user.role.value,
|
||||
}
|
||||
return create_token(payload, settings.secret_key)
|
||||
|
||||
|
||||
@router.post("/validate_token", tags=["Auth"])
|
||||
def validate_token(
|
||||
auth: AuthToken,
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
try:
|
||||
return extract_payload(auth.token, settings.secret_key)
|
||||
except InvalidToken as err:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid token: {err}",
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
# api_code/api/util.py
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import jwt
|
||||
from jwt.exceptions import PyJWTError
|
||||
|
||||
from .deps import Settings
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
class InvalidToken(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def create_token(payload: dict, key: str):
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
data = {
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=24),
|
||||
**payload,
|
||||
}
|
||||
return jwt.encode(data, key, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def extract_payload(token: str, key: str):
|
||||
try:
|
||||
return jwt.decode(token, key, algorithms=[ALGORITHM])
|
||||
except PyJWTError as err:
|
||||
raise InvalidToken(str(err))
|
||||
|
||||
|
||||
def is_admin(
|
||||
settings: Settings, authorization: Optional[str] = None
|
||||
):
|
||||
if authorization is None:
|
||||
return False
|
||||
|
||||
partition_key = (
|
||||
"Bearer" if "Bearer" in authorization else "bearer"
|
||||
)
|
||||
|
||||
*dontcare, token = authorization.partition(
|
||||
f"{partition_key} "
|
||||
)
|
||||
token = token.strip()
|
||||
|
||||
try:
|
||||
payload = extract_payload(token, settings.secret_key)
|
||||
except InvalidToken:
|
||||
return False
|
||||
else:
|
||||
return payload.get("role") == "admin"
|
||||
@@ -0,0 +1,182 @@
|
||||
# api_code/dummy_data.py
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from random import choice, randint, random
|
||||
|
||||
from api.models import (
|
||||
Base,
|
||||
Classes,
|
||||
Roles,
|
||||
Station,
|
||||
Ticket,
|
||||
Train,
|
||||
User,
|
||||
)
|
||||
from faker import Faker
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
DB_URL = "sqlite:///train.db"
|
||||
engine = create_engine(DB_URL)
|
||||
|
||||
|
||||
def new_db(filename):
|
||||
db_file = Path(filename)
|
||||
db_file.unlink(missing_ok=True)
|
||||
|
||||
# then create a fresh DB
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
new_db("train.db")
|
||||
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
|
||||
fake = Faker()
|
||||
|
||||
# USERS
|
||||
|
||||
NUM_USERS = 100
|
||||
NUM_TICKETS = 300
|
||||
NUM_TRAINS = 300
|
||||
|
||||
class_choices = [c for c in Classes]
|
||||
|
||||
users = [
|
||||
User(
|
||||
id=0,
|
||||
full_name="Fabrizio Romano",
|
||||
email="fabrizio.romano@example.com",
|
||||
password=User.hash_password("f4bPassword"),
|
||||
role=Roles.admin,
|
||||
)
|
||||
]
|
||||
|
||||
for user_id in range(1, NUM_USERS + 1):
|
||||
users.append(
|
||||
User(
|
||||
id=user_id,
|
||||
full_name=fake.name(),
|
||||
email=fake.safe_email(),
|
||||
password=User.hash_password(fake.password()),
|
||||
role=Roles.passenger,
|
||||
)
|
||||
)
|
||||
|
||||
session.bulk_save_objects(users)
|
||||
session.commit()
|
||||
|
||||
# STATIONS
|
||||
|
||||
stations = [
|
||||
Station(id=0, code="ROM", country="Italy", city="Rome"),
|
||||
Station(id=1, code="PAR", country="France", city="Paris"),
|
||||
Station(id=2, code="LDN", country="UK", city="London"),
|
||||
Station(id=3, code="KYV", country="Ukraine", city="Kyiv"),
|
||||
Station(
|
||||
id=4, code="STK", country="Sweden", city="Stockholm"
|
||||
),
|
||||
Station(
|
||||
id=5, code="WSW", country="Poland", city="Warsaw"
|
||||
),
|
||||
Station(
|
||||
id=6, code="MSK", country="Russia", city="Moskow"
|
||||
),
|
||||
Station(
|
||||
id=7,
|
||||
code="AMD",
|
||||
country="Netherlands",
|
||||
city="Amsterdam",
|
||||
),
|
||||
Station(
|
||||
id=8, code="EDB", country="Scotland", city="Edinburgh"
|
||||
),
|
||||
Station(
|
||||
id=9, code="BDP", country="Hungary", city="Budapest"
|
||||
),
|
||||
Station(
|
||||
id=10, code="BCR", country="Romania", city="Bucharest"
|
||||
),
|
||||
Station(
|
||||
id=11, code="SFA", country="Bulgaria", city="Sofia"
|
||||
),
|
||||
]
|
||||
|
||||
session.bulk_save_objects(stations)
|
||||
session.commit()
|
||||
|
||||
# TRAINS
|
||||
|
||||
trains = []
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
HOUR = 60 * 60
|
||||
DAY = 24 * HOUR
|
||||
TEN_DAYS = 10 * DAY
|
||||
|
||||
for train_id in range(NUM_TRAINS):
|
||||
station_ids = list(range(len(stations)))
|
||||
station_from = choice(station_ids)
|
||||
station_ids.remove(station_from)
|
||||
station_to = choice(station_ids)
|
||||
|
||||
name = f"{stations[station_from].city} -> {stations[station_to].city}"
|
||||
departure = now + timedelta(
|
||||
seconds=randint(-TEN_DAYS, TEN_DAYS)
|
||||
)
|
||||
arrival = departure + timedelta(
|
||||
seconds=randint(HOUR, DAY)
|
||||
)
|
||||
|
||||
trains.append(
|
||||
Train(
|
||||
id=train_id,
|
||||
name=name,
|
||||
station_from_id=station_from,
|
||||
station_to_id=station_to,
|
||||
departs_at=departure,
|
||||
arrives_at=arrival,
|
||||
first_class=randint(0, 5),
|
||||
second_class=randint(1, 10),
|
||||
seats_per_car=choice([10, 25, 40]),
|
||||
)
|
||||
)
|
||||
|
||||
session.bulk_save_objects(trains)
|
||||
session.commit()
|
||||
|
||||
# TICKETS
|
||||
|
||||
tickets = []
|
||||
classes = [c for c in Classes]
|
||||
MIN_PRICE = 5
|
||||
MAX_PRICE = 200
|
||||
|
||||
for ticket_id in range(NUM_TICKETS):
|
||||
price = round(
|
||||
float(randint(MIN_PRICE, MAX_PRICE))
|
||||
+ randint(0, 1) * random(),
|
||||
2,
|
||||
)
|
||||
tickets.append(
|
||||
Ticket(
|
||||
id=ticket_id,
|
||||
created_at=now
|
||||
+ timedelta(seconds=randint(-TEN_DAYS, -HOUR)),
|
||||
user_id=choice(
|
||||
range(len(users) - 1)
|
||||
), # last user has no tickets
|
||||
train_id=choice(
|
||||
range(len(trains) - 1)
|
||||
), # last train has no tickets
|
||||
price=price,
|
||||
car_class=choice(classes),
|
||||
)
|
||||
)
|
||||
|
||||
session.bulk_save_objects(tickets)
|
||||
session.commit()
|
||||
|
||||
print("done")
|
||||
@@ -0,0 +1,20 @@
|
||||
# api_code/main.py
|
||||
from api import admin, config, stations, tickets, trains, users
|
||||
from fastapi import FastAPI
|
||||
|
||||
settings = config.Settings()
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.include_router(admin.router)
|
||||
app.include_router(stations.router)
|
||||
app.include_router(trains.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(tickets.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {
|
||||
"message": f"Welcome to version {settings.api_version} of our API"
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
# HTTP queries
|
||||
|
||||
These are example queries that exercise the API.
|
||||
|
||||
Try all of them, especially those that create or delete resources,
|
||||
should be tried twice, consecutively, so you can also see the error
|
||||
messages that the API will return.
|
||||
|
||||
*Note*: You must install [httpie](https://httpie.io) to run the
|
||||
queries below.
|
||||
|
||||
|
||||
## Root
|
||||
|
||||
### GET
|
||||
|
||||
http http://localhost:8000
|
||||
|
||||
|
||||
## Users
|
||||
|
||||
### GET
|
||||
|
||||
http http://localhost:8000/users
|
||||
|
||||
http http://localhost:8000/users/0
|
||||
|
||||
http http://localhost:8000/users/0/tickets
|
||||
|
||||
### POST
|
||||
|
||||
http POST http://localhost:8000/users full_name="John Doe" email="john.doe@example.com" password="johndoe" role="passenger"
|
||||
|
||||
http POST http://localhost:8000/users/authenticate email="fabrizio.romano@example.com" password="f4bPassword"
|
||||
|
||||
http POST http://localhost:8000/users/validate_token token="..."
|
||||
|
||||
### PUT
|
||||
|
||||
http PUT http://localhost:8000/users/101 full_name="Fabrizio Romano" email="fab109@example.com" password="something" role="admin"
|
||||
|
||||
Also available partial updates:
|
||||
|
||||
http PUT http://localhost:8000/users/101 role="passenger"
|
||||
|
||||
### DELETE
|
||||
|
||||
http DELETE http://localhost:8000/users/101
|
||||
|
||||
|
||||
## Stations
|
||||
|
||||
### GET
|
||||
|
||||
http http://localhost:8000/stations
|
||||
|
||||
http http://localhost:8000/stations?code=LDN
|
||||
|
||||
http http://localhost:8000/stations/0
|
||||
|
||||
http http://localhost:8000/stations/0/departures
|
||||
|
||||
http http://localhost:8000/stations/0/arrivals
|
||||
|
||||
### POST
|
||||
|
||||
http POST http://localhost:8000/stations code=TMP country=Temporary-Country city=tmp-city
|
||||
|
||||
### PUT
|
||||
|
||||
http PUT http://localhost:8000/stations/12 code=SMC country=Some-Country city=Some-city
|
||||
|
||||
Also available partial updates:
|
||||
|
||||
http PUT http://localhost:8000/stations/12 code=xxx
|
||||
|
||||
### DELETE
|
||||
|
||||
http DELETE http://localhost:8000/stations/12
|
||||
|
||||
|
||||
## Trains
|
||||
|
||||
### GET
|
||||
|
||||
http http://localhost:8000/trains
|
||||
|
||||
http http://localhost:8000/trains?station_from_code=BCR
|
||||
http http://localhost:8000/trains?station_to_code=STK
|
||||
http "http://localhost:8000/trains?station_from_code=STK&station_to_code=AMD"
|
||||
http "http://localhost:8000/trains?station_from_code=STK&station_to_code=AMD&include_all=True"
|
||||
|
||||
http http://localhost:8000/trains/0
|
||||
|
||||
### POST
|
||||
|
||||
http POST http://localhost:8000/trains name="Pendolino" first_class=2 second_class=4 seats_per_car=8 station_from_id=0 station_to_id=3 arrives_at="2021-08-18T11:33:20" departs_at="2021-08-18T09:55:20"
|
||||
|
||||
### DELETE
|
||||
|
||||
http DELETE http://localhost:8000/trains/300
|
||||
|
||||
|
||||
## Tickets
|
||||
|
||||
### GET
|
||||
|
||||
http http://localhost:8000/tickets
|
||||
|
||||
http http://localhost:8000/tickets/0
|
||||
|
||||
### POST
|
||||
|
||||
http POST http://localhost:8000/tickets user_id=0 train_id=0 price=19.84 car_class="first"
|
||||
|
||||
### DELETE
|
||||
|
||||
http DELETE http://localhost:8000/tickets/300
|
||||
|
||||
|
||||
## Admin
|
||||
|
||||
### GET
|
||||
|
||||
http DELETE http://localhost:8000/admin/stations/10 Authorization:"Bearer admin.token.here"
|
||||
Binary file not shown.
Reference in New Issue
Block a user