diff --git a/kontor-api/src/apis/utils.py b/kontor-api/src/apis/utils.py index 716d2be..6d1ff22 100644 --- a/kontor-api/src/apis/utils.py +++ b/kontor-api/src/apis/utils.py @@ -1,4 +1,13 @@ from typing import Annotated +from typing import Dict +from typing import Optional + +from fastapi import HTTPException +from fastapi import Request +from fastapi import status +from fastapi.openapi.models import OAuthFlows as OAuthFlowsModel +from fastapi.security import OAuth2 +from fastapi.security.utils import get_authorization_scheme_param from fastapi import Depends from sqlalchemy.orm import Session @@ -6,3 +15,34 @@ from sqlalchemy.orm import Session from src.db.session import get_db SessionDep = Annotated[Session, Depends(get_db)] + + +class OAuth2PasswordBearerWithCookie(OAuth2): + def __init__( + self, + tokenUrl: str, + scheme_name: Optional[str] = None, + scopes: Optional[Dict[str, str]] = None, + auto_error: bool = True, + ): + if not scopes: + scopes = {} + flows = OAuthFlowsModel(password={"tokenUrl": tokenUrl, "scopes": scopes}) + super().__init__(flows=flows, scheme_name=scheme_name, auto_error=auto_error) + + async def __call__(self, request: Request) -> Optional[str]: + authorization: str = request.cookies.get( + "access_token" + ) # changed to accept access token from httpOnly Cookie + + scheme, param = get_authorization_scheme_param(authorization) + if not authorization or scheme.lower() != "bearer": + if self.auto_error: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + else: + return None + return param diff --git a/kontor-api/src/apis/version1/admin.py b/kontor-api/src/apis/version1/admin.py new file mode 100644 index 0000000..4c7a626 --- /dev/null +++ b/kontor-api/src/apis/version1/admin.py @@ -0,0 +1,69 @@ +import logging +from datetime import timedelta + +import bcrypt +from fastapi import APIRouter, HTTPException, status, Response, Depends +from fastapi.security import OAuth2PasswordRequestForm +from jose import jwt, JWTError +from src.apis.utils import SessionDep, OAuth2PasswordBearerWithCookie +from src.core.config import settings +from src.core.security import create_access_token +from src.db.models.admin import Profile +from src.db.repository.admin import get_profile +from src.schema.admin import Token + +router = APIRouter() + + +def authenticate_user(username: str, password: str, db: SessionDep) -> Profile | None: + user = get_profile(username=username, db=db) + print(user) + if not user: + return None + if bcrypt.checkpw(password.encode(), user.password.encode()): + print("User successful authenticated") + else: + logging.info("Authentication failed!") + return user + + +@router.post("/token", response_model=Token) +def login_for_access_token(response: Response, db: SessionDep, form_data: OAuth2PasswordRequestForm = Depends()): + user = authenticate_user(form_data.username, form_data.password, db) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + ) + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user.email}, expires_delta=access_token_expires + ) + response.set_cookie( + key="access_token", value=f"Bearer {access_token}", httponly=True + ) + return {"access_token": access_token, "token_type": "bearer"} + + +oauth2_scheme = OAuth2PasswordBearerWithCookie(tokenUrl="/api/login/token") + + +def get_current_user_from_token(db: SessionDep, token: str = Depends(oauth2_scheme)): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + ) + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + username: str = payload.get("sub") + print("username/email extracted is ", username) + if username is None: + raise credentials_exception + except JWTError: + raise credentials_exception + user = get_profile(username=username, db=db) + if user is None: + raise credentials_exception + return user diff --git a/kontor-api/src/apis/version1/comic.py b/kontor-api/src/apis/version1/comic.py index 5a70195..8feef55 100644 --- a/kontor-api/src/apis/version1/comic.py +++ b/kontor-api/src/apis/version1/comic.py @@ -8,11 +8,7 @@ from src.schema.comics.artist import ArtistCreation, ArtistDetailResponse, Artis from src.db.models.comic import Comic, Artist, Issue from src.schema.comics.issue import IssueDetailsResponse -router = APIRouter( - prefix="/comic", - tags=["comics"], - responses={404: {"description": "Not found"}}, -) +router = APIRouter() @router.get("/comics") diff --git a/kontor-api/src/apis/version1/media.py b/kontor-api/src/apis/version1/media.py index f6b4b7e..072097a 100644 --- a/kontor-api/src/apis/version1/media.py +++ b/kontor-api/src/apis/version1/media.py @@ -1,22 +1,20 @@ from typing import List, AnyStr -from uuid import UUID -from fastapi import APIRouter, status, HTTPException +from fastapi import APIRouter, status, HTTPException, Depends from sqlalchemy import select, Sequence from src.apis.utils import SessionDep +from src.apis.version1.admin import get_current_user_from_token +from src.db.models.admin import Profile from src.schema.media.file import MediaFileResponse, Link, get_file_details, set_file from src.db.models.media import MediaFile -router = APIRouter( - prefix="/media", - tags=["media"] -) +router = APIRouter() @router.get("/update-titles") def update_titles(db: SessionDep) -> list[MediaFileResponse]: results: list[MediaFileResponse] = [] - files = db.query(MediaFile).filter(MediaFile.review == 1).all() + files = db.query(MediaFile).filter(MediaFile.review == True).all() for mediafile in files: mediafile.update_title() db.add(mediafile) @@ -27,13 +25,13 @@ def update_titles(db: SessionDep) -> list[MediaFileResponse]: @router.get("/files", response_model=List[MediaFileResponse]) -def get_all_files(db: SessionDep, review: bool = False, download: bool = False) -> List[MediaFileResponse]: +def get_all_files(db: SessionDep, review: bool = False, download: bool = False, current_user: Profile = Depends(get_current_user_from_token)) -> List[MediaFileResponse]: results: list[MediaFileResponse] = [] files: Sequence[MediaFile] if review: - files = db.query(MediaFile).filter(MediaFile.review == 1).all() + files = db.query(MediaFile).filter(MediaFile.review == True).all() elif download: - files = db.query(MediaFile).filter(MediaFile.should_download == 1).all() + files = db.query(MediaFile).filter(MediaFile.should_download == True).all() else: files = db.scalars(select(MediaFile)).all() for mediafile in files: @@ -66,8 +64,8 @@ def add_file(new_link: Link, db: SessionDep) -> MediaFileResponse: try: mediaFile: MediaFile = MediaFile() setattr(mediaFile, "url", new_link.url) - setattr(mediaFile, "review", 1) - setattr(mediaFile, "should_download", 1) + setattr(mediaFile, "review", True) + setattr(mediaFile, "should_download", True) db.add(mediaFile) db.commit() except: diff --git a/kontor-api/src/apis/version1/metadata.py b/kontor-api/src/apis/version1/metadata.py new file mode 100644 index 0000000..2860880 --- /dev/null +++ b/kontor-api/src/apis/version1/metadata.py @@ -0,0 +1,26 @@ +from typing import List + +from fastapi import APIRouter + +from src.apis.utils import SessionDep +from src.db.models.metadata import MetaDataTable, MetaDataColumn +from src.db.repository.metadata import get_tables, get_columns +from src.schema.admin import MetaDataTableResponse, MetaDataColumnResponse + +router = APIRouter() + + + +@router.get("/tables") +def get_meta_data_tables(db: SessionDep) -> List[MetaDataTableResponse]: + tables = db.query(MetaDataTable).all() + response: List[MetaDataTableResponse] = get_tables(tables) + return response + + + +@router.get("/columns") +def get_meta_data_columns(db: SessionDep) -> List[MetaDataColumnResponse]: + columns = db.query(MetaDataColumn).all() + response: List[MetaDataColumnResponse] = get_columns(columns) + return response diff --git a/kontor-api/src/apis/version1/tysc.py b/kontor-api/src/apis/version1/tysc.py index 1128771..c8b8bb2 100644 --- a/kontor-api/src/apis/version1/tysc.py +++ b/kontor-api/src/apis/version1/tysc.py @@ -5,11 +5,7 @@ from src.apis.utils import SessionDep from src.schema.tysc.sport import SportResponse from src.db.models.tysc import Sport -router = APIRouter( - prefix="/tysc", - tags=["tysc"], - responses={404: {"description": "Not found"}}, -) +router = APIRouter() @router.get("/sports") def get_all_sports(db: SessionDep) -> List[SportResponse]: diff --git a/kontor-api/src/db/models/database.py b/kontor-api/src/db/models/database.py index 9a69627..0da5f88 100644 --- a/kontor-api/src/db/models/database.py +++ b/kontor-api/src/db/models/database.py @@ -1,10 +1,9 @@ import json import logging -import uuid from datetime import datetime from enum import Enum, auto from pathlib import Path -from typing import Any +from typing import Any, List from sqlalchemy import select from sqlalchemy.exc import IntegrityError @@ -360,7 +359,7 @@ class KontorDB: update_list[link.id] = link.title return update_list - def get_download_list(self) -> list[uuid.UUID]: + def get_download_list(self) -> List[str]: download_list = [] __session__ = sessionmaker(self.engine) _filter = { 'should_download': True} diff --git a/kontor-api/src/db/repository/metadata.py b/kontor-api/src/db/repository/metadata.py new file mode 100644 index 0000000..86a7d04 --- /dev/null +++ b/kontor-api/src/db/repository/metadata.py @@ -0,0 +1,32 @@ +from typing import List + +from src.db.models.metadata import MetaDataTable, MetaDataColumn +from src.schema.admin import MetaDataTableResponse, MetaDataColumnResponse + + +def get_tables(tables: List[MetaDataTable]) -> List[MetaDataTableResponse]: + results: List[MetaDataTableResponse] = [] + for table in tables: + result = MetaDataTableResponse(id=table.id, name=table.table_name) + results.append(result) + return results + +def get_columns(columns: List[MetaDataColumn]) -> List[MetaDataColumnResponse]: + results: List[MetaDataColumnResponse] = [] + for column in columns: + result = MetaDataColumnResponse( + id=column.id, + table_name=column.table.table_name, + column_name=column.column_name, + column_sync_name=column.column_sync_name, + column_type=column.column_type, + column_modifier=column.column_modifier, + column_order=column.column_order, + is_shown=column.is_shown, + column_label=column.column_label, + show_filter=column.show_filter, + filter_label=column.filter_label, + ref_column=column.ref_column + ) + results.append(result) + return results diff --git a/kontor-api/src/main.py b/kontor-api/src/main.py index 3db14d2..ceecc22 100644 --- a/kontor-api/src/main.py +++ b/kontor-api/src/main.py @@ -28,6 +28,12 @@ async def lifespan(app: FastAPI): +@asynccontextmanager +async def lifespan(app: FastAPI): + await check_db_connected() + yield + await check_db_disconnected() + @asynccontextmanager async def lifespan(app: FastAPI): await check_db_connected() diff --git a/kontor-api/src/schema/admin.py b/kontor-api/src/schema/admin.py new file mode 100644 index 0000000..25beb3c --- /dev/null +++ b/kontor-api/src/schema/admin.py @@ -0,0 +1,26 @@ +from typing import Optional + +from pydantic import BaseModel + + +class Token(BaseModel): + access_token: str + token_type: str + +class MetaDataTableResponse(BaseModel): + id: str + name: str + +class MetaDataColumnResponse(BaseModel): + id: str + table_name: str + column_name: str + column_sync_name: str + column_type: str + column_modifier: Optional[str] + column_order: int + is_shown: bool + column_label: Optional[str] + show_filter: bool + filter_label: Optional[str] + ref_column: Optional[str] diff --git a/kontor-api/src/schema/comics/artist.py b/kontor-api/src/schema/comics/artist.py index b2ed979..fd813da 100644 --- a/kontor-api/src/schema/comics/artist.py +++ b/kontor-api/src/schema/comics/artist.py @@ -2,7 +2,6 @@ from typing import List, Dict from pydantic import BaseModel -from src.db.models.comic import Artist diff --git a/kontor-api/src/templates/admin/metadata.html b/kontor-api/src/templates/admin/metadata.html new file mode 100644 index 0000000..ca93b0b --- /dev/null +++ b/kontor-api/src/templates/admin/metadata.html @@ -0,0 +1,21 @@ +{% extends "shared/base.html" %} + +{% block title %} + MetaData +{% endblock %} + +{% block content %} + {% with msg=msg %} + {% include "components/alerts.html" %} + {% endwith %} +
+ {% for table in data %} +
+ {% with obj=table %} + {% include "components/metadatatable_cards.html" %} + {% endwith %} +
+
+ {% endfor %} +
+{% endblock %} diff --git a/kontor-api/src/templates/components/metadatatable_cards.html b/kontor-api/src/templates/components/metadatatable_cards.html new file mode 100644 index 0000000..ca19dbc --- /dev/null +++ b/kontor-api/src/templates/components/metadatatable_cards.html @@ -0,0 +1,35 @@ +
+
+
{{obj.table_name}}
+ + + + + + + + + + + + + + + {% for column in obj.table_columns %} + + + + + + + + + + + + + {% endfor %} + +
Column NameColumn Sync NameColumn TypeColumn ModifierColumn OrderIs ShownColumn LabelShow FilterFilter LabelRef Column
{{column.column_name}}{{column.column_sync_name}}{{column.column_type}}{{column.column_modifier}}{{column.column_order}}{% with check=column.is_shown %}{% include "components/check.html" %}{% endwith %}{{column.column_label}}{% with check=column.show_filter %}{% include "components/check.html" %}{% endwith %}{{column.filter_label}}{{column.ref_column}}
+
+