Backend: first commit

This commit is contained in:
Martin Bauer 2021-08-29 16:01:32 +02:00
parent ee6d7d2e96
commit e5a5e141a9
9 changed files with 413 additions and 0 deletions

2
backend/src/config.py Normal file
View File

@ -0,0 +1,2 @@
DATABASE_URL = "sqlite:///db.sqlite"
JWT_SECRET = "4SmRyfsvG86R9jZQfTshfoDlcxYlueHmkMXJbszp"

21
backend/src/db.py Normal file
View File

@ -0,0 +1,21 @@
import databases
import sqlalchemy
from starlette import requests
from config import DATABASE_URL
from sqlalchemy.ext.declarative import DeclarativeMeta, declarative_base
from sqlalchemy.orm import sessionmaker
from starlette.requests import Request
database = databases.Database(DATABASE_URL)
Base: DeclarativeMeta = declarative_base()
engine = sqlalchemy.create_engine(
DATABASE_URL, connect_args={"check_same_thread": False}
)
DbSession = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db(request: Request):
return request.state.db

38
backend/src/main.py Normal file
View File

@ -0,0 +1,38 @@
from fastapi import FastAPI
from users import add_user_routers, User
from db import database, engine, Base, DbSession
from starlette.requests import Request
from routes import router as api_router
import models
def get_app() -> FastAPI:
application = FastAPI(title="swimtracker", debug=True, version="0.1")
application.include_router(api_router)
add_user_routers(application)
return application
app = get_app()
@app.middleware("http")
async def db_session_middleware(request: Request, call_next):
request.state.db = DbSession()
response = await call_next(request)
request.state.db.close()
return response
@app.on_event("startup")
async def startup() -> None:
print("creating")
Base.metadata.create_all(engine)
if not database.is_connected:
await database.connect()
@app.on_event("shutdown")
async def shutdown() -> None:
if database.is_connected:
await database.disconnect()

42
backend/src/models.py Normal file
View File

@ -0,0 +1,42 @@
from db import Base
from sqlalchemy import Column, Integer, Index, LargeBinary, ForeignKey, and_, or_
from typing import Tuple
class Session(Base):
__tablename__ = "session"
device_id = Column(Integer, primary_key=True)
start_time = Column(Integer, primary_key=True)
data = Column(LargeBinary(1024 * 1024 * 2), nullable=False)
user = Column(ForeignKey("user.id"), nullable=False)
value_right_shift = Column(Integer)
tare_value = Column(Integer)
kg_factor = Column(Integer)
Index('device_id', 'start_time', unique=True)
class FriendRequest(Base):
__tablename__ = "friend_request"
requesting_user = Column(ForeignKey("user.id"), primary_key=True)
receiving_user = Column(ForeignKey("user.id"), primary_key=True)
class Friendship(Base):
__tablename__ = "friendship"
user_id = Column(ForeignKey("user.id"), primary_key=True)
friend_id = Column(ForeignKey("user.id"), primary_key=True)
@staticmethod
def befriend(db, userid1, userid2):
db.add(Friendship(user_id=userid1, friend_id=userid2))
@staticmethod
def are_friends(db, userid1, userid2):
query_filter = or_(
and_(Friendship.user_id == userid1, Friendship.friend_id == userid2),
and_(Friendship.user_id == userid2, Friendship.friend_id == userid1),
)
return db.query(Friendship).filter(query_filter).count() > 0

94
backend/src/routes.py Normal file
View File

@ -0,0 +1,94 @@
import base64
from fastapi import APIRouter, Depends, HTTPException
from typing import List
import schemas
from users import User, UserDB, UserTable, current_user
from db import get_db
import models
from sqlalchemy.orm import Session as DbSession, lazyload
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.exc import IntegrityError
from fastapi import status
from sqlalchemy.sql import select
router = APIRouter()
@router.post("/sessions",
response_model=schemas.Session,
tags=["sessions"],
status_code=status.HTTP_201_CREATED)
def create_session(session: schemas.SessionBase,
db: DbSession = Depends(get_db),
user: User = Depends(current_user)):
session_props = session.dict()
session_props['user'] = user.id
session_props['data'] = base64.b64decode(session_props['data'])
db_obj = models.Session(**session_props)
db.add(db_obj)
db.commit()
return db_obj
@router.get("/sessions", response_model=List[schemas.Session], tags=["sessions"])
def list_sessions(skip=0,
limit=100,
db: DbSession = Depends(get_db),
user: User = Depends(current_user)):
return db.query(models.Session).filter(models.Session.user == user.id).order_by(
models.Session.start_time.desc()).offset(skip).limit(limit).all()
@router.post("/friends/request_friendship/{user_id}", tags=["friends"])
def create_friend_request(other_user_id: str,
db: DbSession = Depends(get_db),
user: User = Depends(current_user)):
if models.Friendship.are_friends(db, other_user_id, user.id):
raise HTTPException(status.HTTP_406_NOT_ACCEPTABLE, detail="already friends")
FR = models.FriendRequest
friend_request_from_other_user = db.query(FR).filter(FR.requesting_user == other_user_id,
FR.receiving_user == user.id).count()
if friend_request_from_other_user > 0:
raise HTTPException(status.HTTP_406_NOT_ACCEPTABLE,
detail="Friend request exist from other user, accept it")
else:
try:
new_friend_request = FR(requesting_user=user.id, receiving_user=other_user_id)
db.add(new_friend_request)
db.commit()
return {"msg": "ok"}
except IntegrityError:
raise HTTPException(status.HTTP_406_NOT_ACCEPTABLE,
detail="Friend request already exists")
@router.post("/friends/accept_friendship/{user_id}", tags=["friends"])
def accept_friend_request(other_user_id: str,
db: DbSession = Depends(get_db),
user: User = Depends(current_user)):
FR = models.FriendRequest
try:
friend_request = db.query(FR).filter(FR.requesting_user == other_user_id,
FR.receiving_user == user.id).one()
except NoResultFound:
raise HTTPException(status_code=404, detail="No matching friend request found")
models.Friendship.befriend(db, other_user_id, user.id)
db.delete(friend_request)
db.commit()
return {"msg": "ok"}
@router.get("/friends", tags=["friends"], response_model=schemas.FriendsInfo)
def list_friends_info(db: DbSession = Depends(get_db), user: User = Depends(current_user)):
user_obj = db.query(UserTable).filter(UserTable.id == user.id).one()
return schemas.FriendsInfo(incoming_requests=user_obj.friend_requests_in,
outgoing_requests=user_obj.friend_requests_out)
# todo: remove friend requests
# todo: remove friendship
# todo: search user by email
# todo: add usernames to users
# todo: search by username

40
backend/src/schemas.py Normal file
View File

@ -0,0 +1,40 @@
from typing import Optional, List
from pydantic import BaseModel, conint, UUID4
from pydantic.networks import EmailStr
class SessionBase(BaseModel):
device_id: int
start_time: conint(gt=1546297200)
data: str
value_right_shift: Optional[conint(ge=0, le=32)]
tare_value: Optional[conint(ge=0)]
kg_factor: Optional[conint(ge=0)]
class Config:
orm_mode = True
class UserInfo(BaseModel):
id: UUID4
email: EmailStr
class Config:
orm_mode = True
class Session(SessionBase):
user: UUID4
class FriendRequestCreate(BaseModel):
other_user_id: int
class FriendsInfo(BaseModel):
incoming_requests: List[UserInfo]
outgoing_requests: List[UserInfo]
class Config:
orm_mode = True

92
backend/src/users.py Normal file
View File

@ -0,0 +1,92 @@
from fastapi_users import FastAPIUsers, models
from fastapi_users.db import SQLAlchemyBaseUserTable, SQLAlchemyUserDatabase
from fastapi_users.authentication import JWTAuthentication
from config import JWT_SECRET
from fastapi import Request
from db import database, Base
from sqlalchemy.orm import relationship, backref
from sqlalchemy import Integer, Column
from fastapi_users.models import BaseUser
class User(models.BaseUser):
pass
class UserCreate(models.BaseUserCreate):
pass
class UserUpdate(User, models.BaseUserUpdate):
pass
class UserDB(User, models.BaseUserDB):
pass
class UserTable(Base, SQLAlchemyBaseUserTable):
#id = Column(Integer, primary_key=True)
sessions = relationship("Session")
friend_requests_in = relationship(
"UserTable",
secondary="friend_request",
primaryjoin=("UserTable.id == FriendRequest.receiving_user"),
secondaryjoin=("UserTable.id == FriendRequest.requesting_user"),
backref=backref("friend_requests_out"))
friends = relationship('UserTable',
secondary="friendship",
primaryjoin=("UserTable.id == Friendship.user_id"),
secondaryjoin=("UserTable.id == Friendship.friend_id"))
user_db = SQLAlchemyUserDatabase(UserDB, database, UserTable.__table__)
jwt_authentication = JWTAuthentication(secret=JWT_SECRET,
lifetime_seconds=60 * 60 * 8,
tokenUrl="auth/jwt/login")
fastapi_users = FastAPIUsers(
user_db,
[jwt_authentication],
User,
UserCreate,
UserUpdate,
UserDB,
)
current_user = fastapi_users.current_user(active=True, verified=True)
current_superuser = fastapi_users.current_user(active=True, superuser=True, verified=True)
def on_after_register(user: UserDB, request: Request):
print(f"User {user.id} has registered.")
def on_after_forgot_password(user: UserDB, token: str, request: Request):
print(f"User {user.id} has forgot their password. Reset token: {token}")
def after_verification_request(user: UserDB, token: str, request: Request):
print(f"Verification requested for user {user.id}. Verification token: {token}")
def add_user_routers(app):
app.include_router(fastapi_users.get_auth_router(jwt_authentication),
prefix="/auth/jwt",
tags=["auth"])
app.include_router(fastapi_users.get_register_router(on_after_register),
prefix="/auth",
tags=["auth"])
app.include_router(
fastapi_users.get_reset_password_router(JWT_SECRET,
after_forgot_password=on_after_forgot_password),
prefix="/auth",
tags=["auth"],
)
app.include_router(
fastapi_users.get_verify_router(JWT_SECRET,
after_verification_request=after_verification_request),
prefix="/auth",
tags=["auth"],
)
app.include_router(fastapi_users.get_users_router(), prefix="/users", tags=["users"])

73
backend/tests/conftest.py Normal file
View File

@ -0,0 +1,73 @@
import os
from typing import Any, Generator
import pytest
from src.db import Base, get_db
from src.main import app as _app
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# Default to using sqlite in memory for fast tests.
# Can be overridden by environment variable for testing in CI against other
# database engines
SQLALCHEMY_DATABASE_URL = os.getenv('TEST_DATABASE_URL', "sqlite://")
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
Session = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture(autouse=True)
def app() -> Generator[FastAPI, Any, None]:
"""
Create a fresh database on each test case.
"""
Base.metadata.create_all(engine) # Create the tables.
yield _app
Base.metadata.drop_all(engine)
@pytest.fixture
def db_session(app: FastAPI) -> Generator[Session, Any, None]:
"""
Creates a fresh sqlalchemy session for each test that operates in a
transaction. The transaction is rolled back at the end of each test ensuring
a clean state.
"""
# connect to the database
connection = engine.connect()
# begin a non-ORM transaction
transaction = connection.begin()
# bind an individual Session to the connection
session = Session(bind=connection)
yield session # use the session in tests.
session.close()
# rollback - everything that happened with the
# Session above (including calls to commit())
# is rolled back.
transaction.rollback()
# return connection to the Engine
connection.close()
@pytest.fixture()
def client(app: FastAPI, db_session: Session) -> Generator[TestClient, Any, None]:
"""
Create a new FastAPI TestClient that uses the `db_session` fixture to override
the `get_db` dependency that is injected into routes.
"""
def _get_test_db():
try:
yield db_session
finally:
pass
app.dependency_overrides[get_db] = _get_test_db
with TestClient(app) as client:
yield client

View File

@ -0,0 +1,11 @@
from backend.src.db import DbSession
from src.schemas import Session
from fastapi import FastAPI
from src.db import DbSession
from fastapi.testclient import TestClient
#----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
def test_session_create(app: FastAPI, db_session: DbSession, client: TestClient):
pass