Added data download ability
This commit is contained in:
@@ -7,5 +7,4 @@ COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
7
backend/main.py
Normal file
7
backend/main.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import uvicorn
|
||||
|
||||
from src.app import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run the app with reload and pass app as string
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
@@ -1 +1,10 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
sqlmodel
|
||||
python-dotenv
|
||||
fastapi_jwt_auth
|
||||
aiosqlite
|
||||
passlib
|
||||
requests
|
||||
python-multipart
|
||||
Pillow
|
||||
0
backend/src/__init__.py
Normal file
0
backend/src/__init__.py
Normal file
115
backend/src/app.py
Normal file
115
backend/src/app.py
Normal file
@@ -0,0 +1,115 @@
|
||||
import inspect
|
||||
import re
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
from fastapi.routing import APIRoute
|
||||
from fastapi_jwt_auth import AuthJWT
|
||||
from fastapi_jwt_auth.exceptions import AuthJWTException
|
||||
from pydantic import BaseModel
|
||||
|
||||
import src.settings as settings
|
||||
from src.database.database import init_db
|
||||
from src.exceptions.base_exception import BaseException
|
||||
from src.exceptions.exception_handlers import (auth_exception_handler,
|
||||
base_exception_handler)
|
||||
|
||||
app = FastAPI(title="SignDataCollector", version="0.0.1")
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
await init_db()
|
||||
|
||||
|
||||
class AuthSettings(BaseModel):
|
||||
"""AuthSettings model"""
|
||||
|
||||
authjwt_secret_key: str = settings.JWT_SECRET_KEY
|
||||
# authjwt_denylist_enabled: bool = True
|
||||
authjwt_denylist_enabled: bool = False
|
||||
authjwt_denylist_token_checks: dict = {"access", "refresh"}
|
||||
access_expires: timedelta = timedelta(seconds=settings.ACCESS_EXPIRES)
|
||||
refresh_expires: timedelta = timedelta(seconds=settings.REFRESH_EXPIRES)
|
||||
authjwt_cookie_csrf_protect: bool = True
|
||||
authjwt_token_location: dict = {"headers"}
|
||||
authjwt_cookie_samesite: str = "lax"
|
||||
|
||||
|
||||
@AuthJWT.load_config
|
||||
def auth_config():
|
||||
return AuthSettings()
|
||||
|
||||
# Add middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# include the routers
|
||||
from .routers import auth_router, signs_router, signvideo_router
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(signs_router)
|
||||
app.include_router(signvideo_router)
|
||||
|
||||
# Add the exception handlers
|
||||
app.add_exception_handler(BaseException, base_exception_handler)
|
||||
app.add_exception_handler(AuthJWTException, auth_exception_handler)
|
||||
|
||||
def custom_openapi():
|
||||
"""custom_openapi generate the custom swagger api documentation
|
||||
|
||||
:return: custom openapi_schema
|
||||
:rtype: openapi_schema
|
||||
"""
|
||||
|
||||
if app.openapi_schema:
|
||||
return app.openapi_schema
|
||||
|
||||
openapi_schema = get_openapi(
|
||||
title="My Auth API",
|
||||
version="1.0",
|
||||
description="An API with an Authorize Button",
|
||||
routes=app.routes,
|
||||
# servers=[{"url": config.api_path}],
|
||||
)
|
||||
|
||||
openapi_schema["components"]["securitySchemes"] = {
|
||||
"Bearer Auth": {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "Authorization",
|
||||
"description": "Enter: **'Bearer <JWT>'**, where JWT is the access token",
|
||||
}
|
||||
}
|
||||
|
||||
# Get all routes where jwt_optional() or jwt_required
|
||||
api_router = [route for route in app.routes if isinstance(route, APIRoute)]
|
||||
|
||||
for route in api_router:
|
||||
path = getattr(route, "path")
|
||||
endpoint = getattr(route, "endpoint")
|
||||
methods = [method.lower() for method in getattr(route, "methods")]
|
||||
|
||||
for method in methods:
|
||||
# access_token
|
||||
if (
|
||||
re.search("RoleChecker", inspect.getsource(endpoint))
|
||||
or re.search("jwt_required", inspect.getsource(endpoint))
|
||||
or re.search("fresh_jwt_required", inspect.getsource(endpoint))
|
||||
or re.search("jwt_optional", inspect.getsource(endpoint))
|
||||
):
|
||||
openapi_schema["paths"][path][method]["security"] = [
|
||||
{"Bearer Auth": []}
|
||||
]
|
||||
|
||||
app.openapi_schema = openapi_schema
|
||||
return app.openapi_schema
|
||||
|
||||
|
||||
app.openapi = custom_openapi
|
||||
129
backend/src/database/crud.py
Normal file
129
backend/src/database/crud.py
Normal file
@@ -0,0 +1,129 @@
|
||||
""" This module includes the create, read, update and delete functions for the models """
|
||||
|
||||
from typing import List, Optional, Type, TypeVar
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
T = TypeVar("T", SQLModel, object)
|
||||
|
||||
|
||||
async def read_all_where(
|
||||
model: T, *args, select_in_load: List = [], session: AsyncSession
|
||||
) -> List[T]:
|
||||
"""read_all_where this function reads all the entries from a specific model,
|
||||
if a key and a value are passed this will be checked on each instance of model
|
||||
|
||||
example read all Users: read_all_where(User)
|
||||
example read all Users with Role admin: read_all_where(User, User.role, UserRole.ADMIN)
|
||||
|
||||
:param model: the model to read all entries from
|
||||
:type model: SQLModel
|
||||
:param key: the key to check the value on, pass this argument as SQLModel.key (see the example)
|
||||
:param value: the value that the key should have
|
||||
:return: list with all data-entries of type model
|
||||
:rtype: List[SQLModel]
|
||||
"""
|
||||
statement = select(model)
|
||||
for arg in args:
|
||||
statement = statement.where(arg)
|
||||
for sil in select_in_load:
|
||||
statement = statement.options(selectinload(sil))
|
||||
res = await session.execute(statement)
|
||||
return [value for (value,) in res.all()]
|
||||
|
||||
|
||||
async def read_where(
|
||||
model: T, *args, select_in_load: List = [], session: AsyncSession
|
||||
) -> Optional[T]:
|
||||
"""read_where this function reads one entry from a specific model that matches the given key and value
|
||||
|
||||
example read user with id user_id: read_where(User, User.id == user_id)
|
||||
|
||||
:param model: the model to read all entries from
|
||||
:type model: SQLModel
|
||||
:param key: the key to check the value on, pass this argument as SQLModel.key (see the example)
|
||||
:param value: the value that the key should have
|
||||
:return: a data-entry of type model that has values as values for the keys, or None is no such data-entry could be found
|
||||
:rtype: Optional[SQLModel]
|
||||
"""
|
||||
statement = select(model)
|
||||
for arg in args:
|
||||
statement = statement.where(arg)
|
||||
for sil in select_in_load:
|
||||
statement = statement.options(selectinload(sil))
|
||||
res = await session.execute(statement)
|
||||
first = res.first()
|
||||
if not first:
|
||||
return None
|
||||
return first[0]
|
||||
|
||||
|
||||
async def count_where(model: Type[T], *args, session: AsyncSession) -> int:
|
||||
"""count_where Count the objects where in mongodb
|
||||
|
||||
:param model: _description_
|
||||
:type model: Type[SQLModel]
|
||||
:return: the object count
|
||||
:rtype: int
|
||||
"""
|
||||
|
||||
statement = select(model)
|
||||
for arg in args:
|
||||
statement = statement.where(arg)
|
||||
res = await session.execute(statement)
|
||||
if res is None:
|
||||
return 0
|
||||
return len(res.all())
|
||||
|
||||
|
||||
async def update(model: T, session: AsyncSession) -> Optional[T]:
|
||||
"""update this function updates one entry from a model (the one with the same id, or else it adds the id)
|
||||
|
||||
example new_user is the updated version of the old user but the id remained: update(new_user)
|
||||
|
||||
:param model: an instance of the model to update / create
|
||||
:type model: SQLModel
|
||||
:return: the updated user upon succes
|
||||
:rtype: Optional[SQLModel]
|
||||
"""
|
||||
|
||||
session.add(model)
|
||||
await session.commit()
|
||||
await session.refresh(model)
|
||||
return model
|
||||
|
||||
|
||||
async def update_all(models: List[T], session: AsyncSession) -> Optional[List[T]]:
|
||||
"""update this function updates all entries from models (the one with the same id, or else it adds the id)
|
||||
|
||||
example new_user is the updated version of the old user but the id remained: update(new_user)
|
||||
|
||||
:param models: a list of instances of models to update / create
|
||||
:type models: List[SQLModel]
|
||||
:return: the updated user upon success
|
||||
:rtype: Optional[SQLModel]
|
||||
"""
|
||||
|
||||
session.add_all(models)
|
||||
await session.commit()
|
||||
|
||||
for model in models:
|
||||
await session.refresh(model)
|
||||
|
||||
return models
|
||||
|
||||
|
||||
async def delete(model: T, session: AsyncSession) -> None:
|
||||
"""Deletes the given model from the database
|
||||
|
||||
:param model: an instance of the model to delete
|
||||
:type model: SQLModel
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
|
||||
await session.delete(model)
|
||||
await session.commit()
|
||||
59
backend/src/database/database.py
Normal file
59
backend/src/database/database.py
Normal file
@@ -0,0 +1,59 @@
|
||||
""" This module includes the function for the database connection """
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
import src.settings as settings
|
||||
from src.models.auth import User
|
||||
from src.utils.cryptography import get_password_hash
|
||||
|
||||
|
||||
async def init_db(fill=True):
|
||||
from src.database.engine import engine
|
||||
|
||||
async with engine.begin() as conn:
|
||||
# await conn.run_sync(SQLModel.metadata.drop_all)
|
||||
if settings.TEST_MODE or settings.DB_USE_SQLITE:
|
||||
await conn.execute(text("PRAGMA foreign_keys=ON"))
|
||||
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
if settings.DEFAULT_USER_ENABLED:
|
||||
async_session = sessionmaker(
|
||||
engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
async with async_session() as session:
|
||||
# check if there exists a user
|
||||
users = await User.get_all(session=session)
|
||||
if len(users) == 0:
|
||||
# create a new user
|
||||
user = User(
|
||||
email=settings.DEFAULT_USER_EMAIL,
|
||||
hashed_password=get_password_hash(settings.DEFAULT_USER_PASSWORD),
|
||||
)
|
||||
await user.save(session=session)
|
||||
|
||||
if fill:
|
||||
async_session = sessionmaker(
|
||||
engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
async with async_session() as session:
|
||||
# await fill_database(session=session)
|
||||
pass
|
||||
|
||||
|
||||
async def get_session() -> AsyncSession:
|
||||
"""get_session return a new session
|
||||
|
||||
:return: a new session
|
||||
:rtype: AsyncSession
|
||||
:yield: a new session
|
||||
:rtype: Iterator[AsyncSession]
|
||||
"""
|
||||
from src.database.engine import engine
|
||||
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
47
backend/src/database/engine.py
Normal file
47
backend/src/database/engine.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import json
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
import pydantic
|
||||
from sqlalchemy.engine import URL
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
|
||||
import src.settings as settings
|
||||
|
||||
engine: AsyncEngine
|
||||
|
||||
|
||||
def _custom_json_serializer(*args, **kwargs) -> str:
|
||||
"""
|
||||
Encodes json in the same way that pydantic does.
|
||||
"""
|
||||
return json.dumps(*args, default=pydantic.json.pydantic_encoder, **kwargs)
|
||||
|
||||
|
||||
# check if the engine must be created for the tests -> in memory sqlite
|
||||
if settings.TEST_MODE:
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
json_serializer=_custom_json_serializer,
|
||||
)
|
||||
elif settings.DB_USE_SQLITE:
|
||||
engine = create_async_engine(
|
||||
URL.create(drivername="sqlite+aiosqlite", database=settings.DB_SQLITE_PATH),
|
||||
connect_args={"check_same_thread": False},
|
||||
json_serializer=_custom_json_serializer,
|
||||
)
|
||||
else:
|
||||
# use the postgresql database
|
||||
_encoded_password = quote_plus(settings.DB_PASSWORD)
|
||||
engine = create_async_engine(
|
||||
URL.create(
|
||||
drivername="postgresql+asyncpg",
|
||||
username=settings.DB_USER,
|
||||
password=_encoded_password,
|
||||
host=settings.DB_HOST,
|
||||
port=settings.DB_PORT,
|
||||
database=settings.DB_NAME,
|
||||
json_serializer=_custom_json_serializer,
|
||||
),
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
47
backend/src/exceptions/base_exception.py
Normal file
47
backend/src/exceptions/base_exception.py
Normal file
@@ -0,0 +1,47 @@
|
||||
""" This module includes the BaseException
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
class BaseException(Exception):
|
||||
"""BaseException is the base class for all exceptions
|
||||
|
||||
:param Exception: inherits from python Exception class
|
||||
"""
|
||||
|
||||
def __init__(self, status_code: int, message: str):
|
||||
"""__init__ init the class with the status code and message
|
||||
|
||||
:param status_code: the status code of the response
|
||||
:type status_code: int
|
||||
:param message: the message of exception
|
||||
:type message: str
|
||||
"""
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
|
||||
def json(self) -> JSONResponse:
|
||||
"""json return the json of the exception
|
||||
|
||||
:return: json reponse of exception
|
||||
:rtype: JSONResponse
|
||||
"""
|
||||
return JSONResponse(
|
||||
status_code=self.status_code, content={"message": self.message}
|
||||
)
|
||||
|
||||
def checkResponse(self, response: BaseException) -> bool:
|
||||
"""checkResponse compare the response exception with the exception
|
||||
|
||||
:param response: the response exception
|
||||
:type response: _type_
|
||||
:return: if excepiton equals the response exception
|
||||
:rtype: bool
|
||||
"""
|
||||
return (
|
||||
response.status_code == self.status_code
|
||||
and json.loads(response.content)["message"] == self.message
|
||||
)
|
||||
31
backend/src/exceptions/exception_handlers.py
Normal file
31
backend/src/exceptions/exception_handlers.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi_jwt_auth.exceptions import AuthJWTException
|
||||
|
||||
|
||||
async def base_exception_handler(request: Request, exception: BaseException):
|
||||
"""my_exception_handler handler for the raised exceptions
|
||||
|
||||
:param request: the request
|
||||
:type request: Request
|
||||
:param exception: the raised exception
|
||||
:type exception: BaseException
|
||||
:return: the exception in json format
|
||||
:rtype: JSONResponse
|
||||
"""
|
||||
return exception.json()
|
||||
|
||||
|
||||
async def auth_exception_handler(request: Request, exception: AuthJWTException):
|
||||
"""auth_exception_handler handler for the raised exceptions
|
||||
|
||||
:param request: the request
|
||||
:type request: Request
|
||||
:param exception: the raised exception
|
||||
:type exception: AuthJWTException
|
||||
:return: the exception in json format
|
||||
:rtype: JSONResponse
|
||||
"""
|
||||
return JSONResponse(
|
||||
status_code=exception.status_code, content={"message": exception.message}
|
||||
)
|
||||
11
backend/src/exceptions/login_exception.py
Normal file
11
backend/src/exceptions/login_exception.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from src.exceptions.base_exception import BaseException
|
||||
|
||||
|
||||
class LoginException(BaseException):
|
||||
"""LoginException exception raised when something went wrong during the login process
|
||||
:param BaseException: inherits from BaseException
|
||||
"""
|
||||
|
||||
def __init__(self, message: str = ""):
|
||||
"""__init__ inits parent class with status code and message"""
|
||||
super().__init__(400, message)
|
||||
53
backend/src/models/SQLModelExtended.py
Normal file
53
backend/src/models/SQLModelExtended.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import SQLModel
|
||||
from src.database.crud import delete, read_all_where, read_where, update
|
||||
|
||||
|
||||
class SQLModelExtended(SQLModel):
|
||||
__abstract__ = True
|
||||
|
||||
@classmethod
|
||||
async def get_all(
|
||||
self, session: AsyncSession, select_in_load: List = []
|
||||
) -> List[SQLModel]:
|
||||
res = await read_all_where(self, select_in_load=select_in_load, session=session)
|
||||
return res
|
||||
|
||||
async def save(self, session: AsyncSession):
|
||||
await update(self, session=session)
|
||||
|
||||
@classmethod
|
||||
async def get_by_id(
|
||||
self, id: int, session: AsyncSession, select_in_load: List = []
|
||||
) -> Optional[SQLModel]:
|
||||
res = await read_where(
|
||||
self, self.id == id, select_in_load=select_in_load, session=session
|
||||
)
|
||||
return res
|
||||
|
||||
@classmethod
|
||||
async def get_where(self, *args, session: AsyncSession):
|
||||
res = await read_where(self, *args, session=session)
|
||||
return res
|
||||
|
||||
@classmethod
|
||||
async def get_all_where(self, *args, session: AsyncSession):
|
||||
res = await read_all_where(self, *args, session=session)
|
||||
return res
|
||||
|
||||
async def delete(self, session: AsyncSession) -> None:
|
||||
await delete(self, session=session)
|
||||
|
||||
async def update_from(self, data, session: AsyncSession) -> None:
|
||||
data_dict = data.dict(exclude_unset=True)
|
||||
for key in data_dict.keys():
|
||||
setattr(self, key, getattr(data, key))
|
||||
await update(self, session=session)
|
||||
|
||||
@classmethod
|
||||
async def delete_by_id(self, id: int, session: AsyncSession) -> None:
|
||||
res = await read_where(self, self.id == id, session=session)
|
||||
await delete(res, session=session)
|
||||
26
backend/src/models/auth.py
Normal file
26
backend/src/models/auth.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import Field, Relationship
|
||||
|
||||
from src.database.crud import read_where
|
||||
from src.models.SQLModelExtended import SQLModelExtended
|
||||
|
||||
|
||||
class Login(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class User(SQLModelExtended, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
email: str
|
||||
hashed_password: str
|
||||
|
||||
@classmethod
|
||||
async def get_by_email(self, email: str, session: AsyncSession) -> "User":
|
||||
return await read_where(self, self.email == email, session=session)
|
||||
61
backend/src/models/sign.py
Normal file
61
backend/src/models/sign.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from typing import List
|
||||
|
||||
import requests
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Field, Relationship, SQLModel
|
||||
|
||||
from src.exceptions.base_exception import BaseException
|
||||
from src.models.signvideo import SignVideo
|
||||
from src.models.SQLModelExtended import SQLModelExtended
|
||||
|
||||
|
||||
class Sign(SQLModelExtended, table=True):
|
||||
id: int = Field(primary_key=True)
|
||||
|
||||
url: str
|
||||
|
||||
name: str
|
||||
sign_id: str
|
||||
video_url: str
|
||||
|
||||
sign_videos: List[SignVideo] = Relationship(
|
||||
back_populates="sign",
|
||||
sa_relationship_kwargs={"lazy": "selectin"},
|
||||
)
|
||||
|
||||
def __init__(self, url):
|
||||
self.url = url
|
||||
|
||||
# get name and sign id from url
|
||||
try:
|
||||
t = self.url.split("/")[-1].split("?")
|
||||
self.name = t[0]
|
||||
self.sign_id = t[1].split("=")[1]
|
||||
except:
|
||||
raise BaseException(404, "Invalid url")
|
||||
self.get_video_url()
|
||||
|
||||
def get_video_url(self):
|
||||
try:
|
||||
r = requests.get(f"https://woordenboek.vlaamsegebarentaal.be/api/glosses/{self.name}")
|
||||
res_json = r.json()
|
||||
# find the video url
|
||||
for item in res_json["variants"]:
|
||||
if str(item["signId"]) == str(self.sign_id):
|
||||
self.video_url = item["video"]
|
||||
break
|
||||
except:
|
||||
raise BaseException(404, "Invalid url")
|
||||
|
||||
# throw exception if video url not found
|
||||
if self.video_url is None:
|
||||
raise BaseException(404, "Video url not found")
|
||||
|
||||
class SignOut(BaseModel):
|
||||
id: int
|
||||
url: str
|
||||
name: str
|
||||
sign_id: str
|
||||
video_url: str
|
||||
|
||||
sign_videos: List[SignVideo] = []
|
||||
25
backend/src/models/signvideo.py
Normal file
25
backend/src/models/signvideo.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Field, Relationship, SQLModel
|
||||
|
||||
from src.exceptions.base_exception import BaseException
|
||||
from src.models.SQLModelExtended import SQLModelExtended
|
||||
|
||||
|
||||
class SignVideo(SQLModelExtended, table=True):
|
||||
id: int = Field(primary_key=True)
|
||||
|
||||
approved: bool = False
|
||||
|
||||
# foreign key to sign
|
||||
sign_id: int = Field(default=None, foreign_key="sign.id")
|
||||
sign: "Sign" = Relationship(
|
||||
back_populates="sign_videos",
|
||||
sa_relationship_kwargs={"lazy": "selectin"},
|
||||
)
|
||||
|
||||
# path where video saved
|
||||
path: str
|
||||
|
||||
class SignVideoOut(BaseModel):
|
||||
id: int
|
||||
approved: bool
|
||||
10
backend/src/models/token.py
Normal file
10
backend/src/models/token.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TokenExtended(BaseModel):
|
||||
"""an output model for the tokens (accessToken + refreshToken)"""
|
||||
|
||||
id: str
|
||||
access_token: str
|
||||
access_token_expiry: int
|
||||
refresh_token: str
|
||||
3
backend/src/routers/__init__.py
Normal file
3
backend/src/routers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .auth import router as auth_router
|
||||
from .signs import router as signs_router
|
||||
from .signvideo import router as signvideo_router
|
||||
60
backend/src/routers/auth.py
Normal file
60
backend/src/routers/auth.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi_jwt_auth import AuthJWT
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
import src.settings as settings
|
||||
from src.database.database import get_session
|
||||
from src.exceptions.login_exception import LoginException
|
||||
from src.models.auth import Login, User
|
||||
from src.models.token import TokenExtended
|
||||
from src.utils.cryptography import verify_password
|
||||
|
||||
router = APIRouter(prefix="/auth")
|
||||
|
||||
@router.post("/login")
|
||||
async def login(creds: Login, Authorize: AuthJWT = Depends(), session: AsyncSession = Depends(get_session)):
|
||||
# check if user exists
|
||||
user = await User.get_by_email(creds.email, session=session)
|
||||
|
||||
# check if password is correct
|
||||
if user is None or not verify_password(creds.password, user.hashed_password):
|
||||
raise LoginException("Invalid login credentials")
|
||||
|
||||
access_token = Authorize.create_access_token(
|
||||
subject=user.id,
|
||||
expires_time=datetime.timedelta(seconds=settings.ACCESS_EXPIRES),
|
||||
)
|
||||
refresh_token = Authorize.create_refresh_token(
|
||||
subject=user.id,
|
||||
expires_time=datetime.timedelta(seconds=settings.REFRESH_EXPIRES),
|
||||
)
|
||||
|
||||
|
||||
return TokenExtended(
|
||||
id=str(user.id),
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
access_token_expiry=settings.ACCESS_EXPIRES,
|
||||
)
|
||||
|
||||
@router.get("/refresh")
|
||||
async def refresh(Authorize: AuthJWT = Depends()):
|
||||
Authorize.jwt_refresh_token_required()
|
||||
current_user = Authorize.get_jwt_subject()
|
||||
access_token = Authorize.create_access_token(
|
||||
subject=current_user.id,
|
||||
expires_time=datetime.timedelta(seconds=settings.ACCESS_EXPIRES),
|
||||
)
|
||||
refresh_token = Authorize.create_refresh_token(
|
||||
subject=current_user.id,
|
||||
expires_time=datetime.timedelta(seconds=settings.REFRESH_EXPIRES),
|
||||
)
|
||||
|
||||
return TokenExtended(
|
||||
id=str(current_user.id),
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
access_token_expiry=settings.ACCESS_EXPIRES,
|
||||
)
|
||||
123
backend/src/routers/signs.py
Normal file
123
backend/src/routers/signs.py
Normal file
@@ -0,0 +1,123 @@
|
||||
import datetime
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi_jwt_auth import AuthJWT
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
import src.settings as settings
|
||||
from src.database.database import get_session
|
||||
from src.exceptions.login_exception import LoginException
|
||||
from src.models.auth import Login, User
|
||||
from src.models.sign import Sign, SignOut
|
||||
from src.models.signvideo import SignVideo
|
||||
from src.models.token import TokenExtended
|
||||
|
||||
router = APIRouter(prefix="/signs")
|
||||
|
||||
class SignUrl(BaseModel):
|
||||
url: str
|
||||
|
||||
@router.post("/", status_code=status.HTTP_201_CREATED, response_model=SignOut)
|
||||
async def add_sign(url: SignUrl, Authorize: AuthJWT = Depends(), session: AsyncSession = Depends(get_session)):
|
||||
Authorize.jwt_required()
|
||||
|
||||
user = Authorize.get_jwt_subject()
|
||||
user = await User.get_by_id(id=user, session=session)
|
||||
|
||||
if not user:
|
||||
raise LoginException("User not found")
|
||||
|
||||
sign = Sign(url.url)
|
||||
|
||||
# check if the sign already exists
|
||||
signs = await Sign.get_all_where(Sign.url == sign.url, session=session)
|
||||
if len(signs) > 0:
|
||||
raise LoginException("Sign already exists")
|
||||
|
||||
|
||||
await sign.save(session=session)
|
||||
|
||||
return sign
|
||||
|
||||
@router.get("/", status_code=status.HTTP_200_OK, response_model=list[SignOut])
|
||||
async def get_signs(Authorize: AuthJWT = Depends(), session: AsyncSession = Depends(get_session)):
|
||||
Authorize.jwt_required()
|
||||
|
||||
user = Authorize.get_jwt_subject()
|
||||
user = await User.get_by_id(id=user, session=session)
|
||||
|
||||
if not user:
|
||||
raise LoginException("User not found")
|
||||
|
||||
signs = await Sign.get_all(select_in_load=[Sign.sign_videos],session=session)
|
||||
return signs
|
||||
|
||||
@router.get("/{id}", status_code=status.HTTP_200_OK, response_model=SignOut)
|
||||
async def get_sign(id: int, Authorize: AuthJWT = Depends(), session: AsyncSession = Depends(get_session)):
|
||||
Authorize.jwt_required()
|
||||
|
||||
user = Authorize.get_jwt_subject()
|
||||
user = await User.get_by_id(id=user, session=session)
|
||||
|
||||
if not user:
|
||||
raise LoginException("User not found")
|
||||
|
||||
sign = await Sign.get_by_id(id=id, select_in_load=[Sign.sign_videos], session=session)
|
||||
return sign
|
||||
|
||||
@router.delete("/{id}", status_code=status.HTTP_200_OK)
|
||||
async def delete_sign(id: int, Authorize: AuthJWT = Depends(), session: AsyncSession = Depends(get_session)):
|
||||
Authorize.jwt_required()
|
||||
|
||||
user = Authorize.get_jwt_subject()
|
||||
user = await User.get_by_id(id=user, session=session)
|
||||
|
||||
if not user:
|
||||
raise LoginException("User not found")
|
||||
|
||||
sign = await Sign.get_by_id(id=id, select_in_load=[Sign.sign_videos], session=session)
|
||||
|
||||
if not sign:
|
||||
raise LoginException("Sign not found")
|
||||
|
||||
# delete the sign videos
|
||||
for sign_video in sign.sign_videos:
|
||||
# get path of the sign video
|
||||
path = f"{settings.DATA_PATH}/{sign_video.path}"
|
||||
# delete the file
|
||||
os.remove(path)
|
||||
await sign_video.delete(session=session)
|
||||
|
||||
await sign.delete(session=session)
|
||||
|
||||
return {"message": "Sign deleted"}
|
||||
|
||||
@router.get("/download/all", status_code=status.HTTP_200_OK)
|
||||
async def download_all(Authorize: AuthJWT = Depends(), session: AsyncSession = Depends(get_session)):
|
||||
Authorize.jwt_required()
|
||||
|
||||
user = Authorize.get_jwt_subject()
|
||||
user = await User.get_by_id(id=user, session=session)
|
||||
|
||||
if not user:
|
||||
raise LoginException("User not found")
|
||||
|
||||
# get the approved sign videos
|
||||
|
||||
signs = await SignVideo.get_all_where(SignVideo.approved == True, session=session)
|
||||
|
||||
# extract the paths from the sign videos
|
||||
paths = [sign.path for sign in signs]
|
||||
|
||||
zip_path = f"/tmp/{datetime.datetime.now().timestamp()}.zip"
|
||||
|
||||
# create the zip file
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED, False) as zip_file:
|
||||
for path in paths:
|
||||
zip_file.write(f"{settings.DATA_PATH}/{path}", os.path.basename(path))
|
||||
|
||||
return FileResponse(zip_path, media_type="application/zip", filename="signs.zip")
|
||||
170
backend/src/routers/signvideo.py
Normal file
170
backend/src/routers/signvideo.py
Normal file
@@ -0,0 +1,170 @@
|
||||
import base64
|
||||
import datetime
|
||||
import io
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import subprocess
|
||||
|
||||
from fastapi import APIRouter, Depends, FastAPI, File, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi_jwt_auth import AuthJWT
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
import src.settings as settings
|
||||
from src.database.database import get_session
|
||||
from src.exceptions.login_exception import LoginException
|
||||
from src.models.auth import User
|
||||
from src.models.sign import Sign
|
||||
from src.models.signvideo import SignVideo, SignVideoOut
|
||||
from src.utils.cryptography import verify_password
|
||||
|
||||
|
||||
def extract_thumbnail(video_path):
|
||||
proc = subprocess.run(["ffmpeg", "-i", video_path, "-ss", "00:00:02.000", "-vframes", "1", "-f", "image2pipe", "-"], stdout=subprocess.PIPE)
|
||||
byte_data = proc.stdout
|
||||
return byte_data
|
||||
|
||||
router = APIRouter(prefix="/signs/{sign_id}/video")
|
||||
|
||||
# endpoint to upload a file and save it in the data folder
|
||||
@router.post("/", status_code=status.HTTP_201_CREATED, response_model=SignVideoOut)
|
||||
async def sign_video(
|
||||
sign_id: int,
|
||||
video: UploadFile,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
Authorize: AuthJWT = Depends(),
|
||||
):
|
||||
Authorize.jwt_required()
|
||||
current_user_id: int = Authorize.get_jwt_subject()
|
||||
user = await User.get_by_id(current_user_id, session)
|
||||
if not user:
|
||||
raise LoginException("User not found")
|
||||
sign = await Sign.get_by_id(sign_id, session)
|
||||
if not sign:
|
||||
raise LoginException("Sign not found")
|
||||
|
||||
video.filename = f"{sign.name}!{sign_id}_{datetime.datetime.now().strftime('%Y%m%d%H%M%S%f')}_{''.join(random.choices(string.ascii_uppercase + string.digits, k=5))}.mp4"
|
||||
|
||||
# create the data folder if not exists
|
||||
if not os.path.exists(settings.DATA_PATH):
|
||||
os.makedirs(settings.DATA_PATH)
|
||||
|
||||
sign_video = SignVideo(
|
||||
sign_id=sign_id,
|
||||
path=video.filename
|
||||
)
|
||||
|
||||
with open(settings.DATA_PATH + "/" + video.filename + ".test", 'wb') as f:
|
||||
f.write(video.file.read())
|
||||
|
||||
command = ['ffmpeg', '-y', '-i', settings.DATA_PATH + "/" + video.filename + ".test", '-c:v', 'libx264', '-c:a', 'aac', '-strict', '-2', '-b:a', '192k', '-c:a', 'aac', '-strict', '-2', '-b:a', '192k', settings.DATA_PATH + "/" + video.filename]
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
# delete the temporary file
|
||||
os.remove(settings.DATA_PATH + "/" + video.filename + ".test")
|
||||
|
||||
await sign_video.save(session)
|
||||
|
||||
return sign_video
|
||||
|
||||
@router.get("/{video_id}/thumbnail", status_code=status.HTTP_200_OK)
|
||||
async def sign_video_thumbnail(
|
||||
sign_id: int,
|
||||
video_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
Authorize: AuthJWT = Depends(),
|
||||
):
|
||||
Authorize.jwt_required()
|
||||
current_user_id: int = Authorize.get_jwt_subject()
|
||||
user = await User.get_by_id(current_user_id, session)
|
||||
if not user:
|
||||
raise LoginException("User not found")
|
||||
sign = await Sign.get_by_id(sign_id, session)
|
||||
if not sign:
|
||||
raise LoginException("Sign not found")
|
||||
sign_video = await SignVideo.get_by_id(video_id, session)
|
||||
if not sign_video:
|
||||
raise BaseException("Sign video not found")
|
||||
|
||||
# extract the thumbnail from the video
|
||||
bytes = extract_thumbnail(f"{settings.DATA_PATH}/{sign_video.path}")
|
||||
|
||||
return StreamingResponse(io.BytesIO(bytes), media_type="image/png")
|
||||
|
||||
|
||||
@router.get("/{video_id}", status_code=status.HTTP_200_OK)
|
||||
async def sign_video(
|
||||
sign_id: int,
|
||||
video_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
Authorize: AuthJWT = Depends(),
|
||||
):
|
||||
Authorize.jwt_required()
|
||||
current_user_id: int = Authorize.get_jwt_subject()
|
||||
user = await User.get_by_id(current_user_id, session)
|
||||
if not user:
|
||||
raise LoginException("User not found")
|
||||
sign = await Sign.get_by_id(sign_id, session)
|
||||
if not sign:
|
||||
raise LoginException("Sign not found")
|
||||
sign_video = await SignVideo.get_by_id(video_id, session)
|
||||
if not sign_video:
|
||||
raise BaseException("Sign video not found")
|
||||
|
||||
# return the video
|
||||
return FileResponse(f"{settings.DATA_PATH}/{sign_video.path}", media_type="video/mp4")
|
||||
|
||||
|
||||
class SignVideoUpdate(BaseModel):
|
||||
approved: bool
|
||||
|
||||
@router.patch("/{video_id}", status_code=status.HTTP_200_OK)
|
||||
async def sign_video(
|
||||
sign_id: int,
|
||||
video_id: int,
|
||||
update: SignVideoUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
Authorize: AuthJWT = Depends(),
|
||||
):
|
||||
Authorize.jwt_required()
|
||||
current_user_id: int = Authorize.get_jwt_subject()
|
||||
user = await User.get_by_id(current_user_id, session)
|
||||
if not user:
|
||||
raise LoginException("User not found")
|
||||
sign = await Sign.get_by_id(sign_id, session)
|
||||
if not sign:
|
||||
raise LoginException("Sign not found")
|
||||
sign_video = await SignVideo.get_by_id(video_id, session)
|
||||
if not sign_video:
|
||||
raise BaseException("Sign video not found")
|
||||
|
||||
sign_video.approved = update.approved
|
||||
await sign_video.save(session)
|
||||
|
||||
|
||||
@router.delete("/{video_id}", status_code=status.HTTP_200_OK)
|
||||
async def sign_video(
|
||||
sign_id: int,
|
||||
video_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
Authorize: AuthJWT = Depends(),
|
||||
):
|
||||
Authorize.jwt_required()
|
||||
current_user_id: int = Authorize.get_jwt_subject()
|
||||
user = await User.get_by_id(current_user_id, session)
|
||||
if not user:
|
||||
raise LoginException("User not found")
|
||||
sign = await Sign.get_by_id(sign_id, session)
|
||||
if not sign:
|
||||
raise LoginException("Sign not found")
|
||||
sign_video = await SignVideo.get_by_id(video_id, session)
|
||||
if not sign_video:
|
||||
raise BaseException("Sign video not found")
|
||||
|
||||
await sign_video.delete(session)
|
||||
|
||||
# delete the video
|
||||
os.remove(f"{settings.DATA_PATH}/{sign_video.path}")
|
||||
33
backend/src/settings.py
Normal file
33
backend/src/settings.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
"""Testing"""
|
||||
TEST_MODE: bool = os.getenv("TEST_MODE", False)
|
||||
|
||||
|
||||
"""Database"""
|
||||
DB_NAME: str = os.getenv("DB_NAME", "db")
|
||||
DB_USER: str = os.getenv("DB_USER", "postgres")
|
||||
DB_PASSWORD: str = os.getenv("DB_PASSWORD", "postgres")
|
||||
DB_HOST: str = os.getenv("DB_HOST", "localhost")
|
||||
DB_PORT: str = os.getenv("DB_PORT", "5432")
|
||||
DB_USE_SQLITE: bool = os.getenv("DB_USE_SQLITE", False)
|
||||
DB_SQLITE_PATH: str = os.getenv("DB_SQLITE_PATH", "./sqlite.db")
|
||||
|
||||
"""Storage"""
|
||||
DATA_PATH: str = os.getenv("DATA_PATH", "data")
|
||||
|
||||
|
||||
"""Authentication"""
|
||||
JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY")
|
||||
ACCESS_EXPIRES: int = int(os.getenv("ACCESS_EXPIRES", 360000))
|
||||
REFRESH_EXPIRES: int = int(os.getenv("REFRESH_EXPIRES", 864000))
|
||||
|
||||
|
||||
"""Standard User"""
|
||||
DEFAULT_USER_ENABLED: bool = os.getenv("DEFAULT_USER_ENABLED", False)
|
||||
DEFAULT_USER_EMAIL: str = os.getenv("DEFAULT_USER_EMAIL", "test@test.com")
|
||||
DEFAULT_USER_PASSWORD: str = os.getenv("DEFAULT_USER_PASSWORD", "test")
|
||||
30
backend/src/utils/cryptography.py
Normal file
30
backend/src/utils/cryptography.py
Normal file
@@ -0,0 +1,30 @@
|
||||
""" This module includes the functions used to hash and verify passwords
|
||||
"""
|
||||
|
||||
from passlib.context import CryptContext
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""verify_password
|
||||
|
||||
:param plain_password: password entered by user
|
||||
:type plain_password: str
|
||||
:param hashed_password: hashed password saved in database
|
||||
:type hashed_password: str
|
||||
:return: hashed_password and plain_password matched
|
||||
:rtype: bool
|
||||
"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""get_password_hash get the hash of a password
|
||||
|
||||
:param password: the plain text password
|
||||
:type password: str
|
||||
:return: the hashed password from the plain text password
|
||||
:rtype: str
|
||||
"""
|
||||
return pwd_context.hash(password)
|
||||
Reference in New Issue
Block a user