diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..cee7b74 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter" + }, + "python.formatting.provider": "none" +} diff --git a/____test.py b/____test.py new file mode 100644 index 0000000..4392cd6 --- /dev/null +++ b/____test.py @@ -0,0 +1,4 @@ +from psycopg.sql import SQL, Identifier, Literal + +print(SQL("{} SERIAL NOT NULL").format(Identifier("asset_ref"))) +print(Identifier("asset_ref").as_string(None)) diff --git a/app.py b/app.py index 95d98b3..5df9c3c 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,7 @@ import io from typing import Any from fastapi import FastAPI, status, Header, UploadFile, Response -from starlette.responses import StreamingResponse +from starlette.responses import StreamingResponse, JSONResponse from fastapi.middleware.cors import CORSMiddleware from based import db import psycopg @@ -14,13 +14,16 @@ import uvicorn from dba import * from models import ( AuthModel, - ColumnsDefinitionList, - ErrorResponseDefinition, - ItemDeletionDefinitionList, + ColumnConditionCompat, + CreateUserDefinition, ItemsFieldSelectorList, TableDefinition, - TableListDefinition, - UserDefinition, + UserUpdateDefinition, + OkResponse, + ErrorResponse, + AccessTokenResponse, + TableItemsResponse, + CreateAssetResponse, ) from utils import ( check_if_admin_access_token, @@ -45,7 +48,11 @@ if found: else: minioClient.make_bucket(BUCKET_NAME) -app = FastAPI() +app = FastAPI( + title="Tuuli API", + description="Tuuli API for Tuuli frontend\n\nUse `c2316f9686e7a764688b8c1b4c60c5a088b07a3c23a2f6b7c25915a5118d7acc` as access token to test the API", + version="0.1.0", +) app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -55,107 +62,275 @@ app.add_middleware( ) -@app.post("/api/getAccessToken") +@app.post( + "/api/getAccessToken", + name="Get access token", + responses={ + 200: {"model": AccessTokenResponse, "description": "Successful response"}, + 401: {"model": ErrorResponse, "description": "User not found"}, + }, +) async def getAccessToken(userData: AuthModel): user = check_user(connector, userData.username, userData.password) if not user: - return {"error": "Wrong username or password"} + return JSONResponse( + ErrorResponse(error="Wrong username or password"), + status_code=status.HTTP_401_UNAUTHORIZED, + ) - return {"access_token": user.access_token} + return AccessTokenResponse(access_token=user.access_token) -@app.get("/api/listTables") +@app.get( + "/api/listTables", + name="List tables", + responses={ + 200: {"model": list[TableDefinition], "description": "List of tables"}, + 403: { + "model": ErrorResponse, + "description": "Requesting this endpoint requires admin-level user access token", + }, + }, +) async def listTables( response: Response, access_token: str | None = Header(default=None), -) -> TableListDefinition | ErrorResponseDefinition: - is_admin = check_if_admin_access_token(connector, access_token) - if not is_admin: - return ErrorResponseDefinition(error="Not allowed") - - return TableListDefinition( - tables=[TableDefinition.parse_obj(table) for table in connector.tables()] - ) - - -@app.post("/api/createTable/{tableName}") -async def createTable( - tableName: str, - columns: ColumnsDefinitionList, - access_token: str | None = Header(default=None), ): is_admin = check_if_admin_access_token(connector, access_token) if not is_admin: - return {"error": "Not allowed"} + return JSONResponse( + ErrorResponse(error="Not allowed"), status_code=status.HTTP_403_FORBIDDEN + ) + + return [TableDefinition.parse_obj(table) for table in connector.tables()] + + +@app.post( + "/api/createTable/{tableName}", + name="Create table", + responses={ + 200: {"model": OkResponse, "description": "Table created successfully"}, + 400: { + "model": ErrorResponse, + "description": "Some generic error happened during table creation", + }, + 403: { + "model": ErrorResponse, + "description": "Requesting this endpoint requires admin-level user access token", + }, + 409: { + "model": ErrorResponse, + "description": "Table with this name already exists", + }, + }, +) +async def createTable( + tableName: str, + columns: list[str], + access_token: str | None = Header(default=None), +): + """ + Parameter `columns` should be a list of strings + Each string should be in a following format: + `column_name:column_type[:column_options]` + + Where *column_type* should be one of the following: + - serial + - str + - bool + - datetime + - float + - int + + Also *column_options* can be one of the following: + - unique + - default + + Example: + ```json + [ + "id:serial:primary", + "name:str:unique", + "description:str", + "is_active:bool", + "price:float", + "quantity:int" + ] + ``` + + Notes: + - you cannot use *unique* and *default* at the same time + - in current implementation you cannot use *default*, because there is no way to + specify default value + """ + + is_admin = check_if_admin_access_token(connector, access_token) + if not is_admin: + return JSONResponse( + ErrorResponse(error="Not allowed"), status_code=status.HTTP_403_FORBIDDEN + ) try: - columnsDefinition = parse_columns_from_definition(",".join(columns.columns)) + columnsDefinition = parse_columns_from_definition(",".join(columns)) create_table(connector, tableName, columnsDefinition) except psycopg.errors.UniqueViolation: - return {"error": "Username already exists"} + return JSONResponse( + ErrorResponse(error="Table already exists"), + status_code=status.HTTP_409_CONFLICT, + ) except Exception as e: - return {"error": str(e)} + return JSONResponse( + ErrorResponse(error=str(e)), status_code=status.HTTP_400_BAD_REQUEST + ) - return {"ok": True} + return OkResponse() -@app.post("/api/dropTable/{tableName}") +@app.post( + "/api/dropTable/{tableName}", + name="Drop table", + responses={ + 200: {"model": OkResponse, "description": "Table dropped successfully"}, + 400: { + "model": ErrorResponse, + "description": "Some generic error happened during table creation", + }, + 403: { + "model": ErrorResponse, + "description": "Requesting this endpoint requires admin-level user access token", + }, + }, +) async def dropTable( tableName: str, access_token: str | None = Header(default=None), ): is_admin = check_if_admin_access_token(connector, access_token) if not is_admin: - return {"error": "Not allowed"} + return JSONResponse( + ErrorResponse(error="Not allowed"), status_code=status.HTTP_403_FORBIDDEN + ) try: - drop_table(connector, tableName) + ok, e = drop_table(connector, tableName) + if not ok: + if e: + raise e + raise Exception("Unknown error") except Exception as e: - return {"error": str(e)} + return JSONResponse( + ErrorResponse(error=str(e)), status_code=status.HTTP_400_BAD_REQUEST + ) - return {"ok": True} + return OkResponse() -@app.post("/api/createUser") +@app.post( + "/api/createUser", + name="Create user", + responses={ + 200: {"model": OkResponse, "description": "Table dropped successfully"}, + 400: { + "model": ErrorResponse, + "description": "Some generic error happened during user creation", + }, + 403: { + "model": ErrorResponse, + "description": "Requesting this endpoint requires admin-level user access token", + }, + 409: { + "model": ErrorResponse, + "description": "User with this username already exists", + }, + }, +) async def createUser( - user: UserDefinition, + user: CreateUserDefinition, access_token: str | None = Header(default=None), ): is_admin = check_if_admin_access_token(connector, access_token) if not is_admin: - return {"error": "Not allowed"} + return JSONResponse( + ErrorResponse(error="Not allowed"), status_code=status.HTTP_403_FORBIDDEN + ) try: - create_user(connector, user.username, user.password) + ok, e = create_user(connector, user.username, user.password) + if not ok: + if e: + raise e + raise Exception("Unknown error") except psycopg.errors.UniqueViolation: - return {"error": "Username already exists"} + return JSONResponse( + ErrorResponse(error="Username already exists"), + status_code=status.HTTP_409_CONFLICT, + ) except Exception as e: - return {"error": str(e)} + return JSONResponse( + ErrorResponse(error=str(e)), status_code=status.HTTP_400_BAD_REQUEST + ) - return {"ok": True} + return OkResponse() -@app.post("/api/updateUser") +@app.post( + "/api/updateUser", + name="Update user", + responses={ + 200: {"model": OkResponse, "description": "Table dropped successfully"}, + 400: { + "model": ErrorResponse, + "description": "Some generic error happened during updating user", + }, + 403: { + "model": ErrorResponse, + "description": "Requesting this endpoint requires admin-level user access token", + }, + }, +) async def updateUser( - user: UserDefinition, + user: UserUpdateDefinition, access_token: str | None = Header(default=None), ): is_admin = check_if_admin_access_token(connector, access_token) if not is_admin: - return {"error": "Not allowed"} - - if not user.user_id or not user.password or not user.access_token: - return {"error": "Malformed request"} + return JSONResponse( + ErrorResponse(error="Not allowed"), status_code=status.HTTP_403_FORBIDDEN + ) try: - update_user(connector, user.user_id, user.password, user.access_token) + ok, e = update_user(connector, user.user_id, user.password, user.access_token) + if not ok: + if e: + raise e + raise Exception("Unknown error") except Exception as e: - return {"error": str(e)} + return JSONResponse( + ErrorResponse(error=str(e)), status_code=status.HTTP_400_BAD_REQUEST + ) - return {"ok": True} + return OkResponse() -@app.post("/items/{tableName}") +@app.post( + "/items/{tableName}", + name="Get items from table", + responses={ + 200: {"model": TableItemsResponse, "description": "Table items"}, + 400: { + "model": ErrorResponse, + "description": "Some generic error happened during getting table items", + }, + 403: { + "model": ErrorResponse, + "description": "Requesting this endpoint requires user access token", + }, + 404: { + "model": ErrorResponse, + "description": "Table not found", + }, + }, +) async def items( tableName: str, selector: ItemsFieldSelectorList, @@ -163,11 +338,16 @@ async def items( ): table_info = connector.getTable(tableName) if not table_info: - return {"error": "Not allowed"} + return JSONResponse( + ErrorResponse(error="Table not found"), + status_code=status.HTTP_404_NOT_FOUND, + ) is_admin = check_if_admin_access_token(connector, access_token) if table_info["system"] and not is_admin: - return {"error": "Not allowed"} + return JSONResponse( + ErrorResponse(error="Not allowed"), status_code=status.HTTP_403_FORBIDDEN + ) columns = parse_columns_from_definition(table_info["columns"]) columnsNames = set(column.name for column in columns) @@ -175,47 +355,87 @@ async def items( if userSelectedColumns != ["*"]: for column in userSelectedColumns: if column not in columnsNames: - return {"error": f"Column {column} not found on table {tableName}"} + return JSONResponse( + ErrorResponse( + error=f"Column {column} not found on table {tableName}" + ), + status_code=status.HTTP_404_NOT_FOUND, + ) else: - userSelectedColumns = columnsNames + userSelectedColumns = list(columnsNames) user, group = get_user_by_access_token(connector, access_token) if not user: - return {"error": "Not allowed"} + return JSONResponse( + ErrorResponse(error="Not allowed"), status_code=status.HTTP_403_FORBIDDEN + ) if not is_admin: allowedColumns = get_allowed_columns_for_group( connector, tableName, group.id if group else -1 ) if not allowedColumns: - return {"error": "Not allowed"} + return JSONResponse( + ErrorResponse(error="Not allowed"), + status_code=status.HTTP_403_FORBIDDEN, + ) elif len(allowedColumns) == 1 and allowedColumns[0] == "*": pass else: for column in userSelectedColumns: if column not in allowedColumns: - return {"error": "Not allowed"} + return JSONResponse( + ErrorResponse(error="Not allowed"), + status_code=status.HTTP_403_FORBIDDEN, + ) table_items = connector.selectFromTable( tableName, selector.fields if selector.fields else ["*"] ) - return {"items": table_items} + return TableItemsResponse(items=table_items) -@app.post("/items/{tableName}/+") +@app.post( + "/items/{tableName}/+", + name="Create item", + responses={ + 200: {"model": OkResponse, "description": "Item created successfully"}, + 400: { + "model": ErrorResponse, + "description": "Some generic error happened during creating item", + }, + 403: { + "model": ErrorResponse, + "description": "Requesting this endpoint requires user access token", + }, + 404: { + "model": ErrorResponse, + "description": "Table or column not found", + }, + 409: { + "model": ErrorResponse, + "description": "Unique constraint violation", + }, + }, +) async def itemsCreate( tableName: str, - item: dict[str, str], + item: dict[str, Any], access_token: str | None = Header(default=None), ): table_info = connector.getTable(tableName) if not table_info: - return {"error": "Not found"} + return JSONResponse( + ErrorResponse(error="Table not found"), + status_code=status.HTTP_404_NOT_FOUND, + ) is_admin = check_if_admin_access_token(connector, access_token) if table_info["system"] and not is_admin: - return {"error": "Not allowed"} + return JSONResponse( + ErrorResponse(error="Not allowed"), status_code=status.HTTP_403_FORBIDDEN + ) user, group = get_user_by_access_token(connector, access_token) if not is_admin: @@ -223,27 +443,63 @@ async def itemsCreate( connector, tableName, group.id if group else -1 ) if not allowedColumns: - return {"error": "Not allowed"} + return JSONResponse( + ErrorResponse(error="Not allowed"), + status_code=status.HTTP_403_FORBIDDEN, + ) elif len(allowedColumns) == 1 and allowedColumns[0] == "*": pass else: for column in item: if column not in allowedColumns: - return {"error": "Not allowed"} + return JSONResponse( + ErrorResponse(error="Not allowed"), + status_code=status.HTTP_403_FORBIDDEN, + ) try: connector.insertIntoTable(tableName, item) except psycopg.errors.UndefinedColumn: - return {"error": "Column not found"} + return JSONResponse( + ErrorResponse(error="Column not found"), + status_code=status.HTTP_404_NOT_FOUND, + ) except psycopg.errors.UniqueViolation: - return {"error": "Unique constraint violation"} + return JSONResponse( + ErrorResponse(error="Unique violation"), + status_code=status.HTTP_409_CONFLICT, + ) except Exception as e: - return {"error": str(e)} + return JSONResponse( + ErrorResponse(error=str(e)), status_code=status.HTTP_400_BAD_REQUEST + ) - return {"ok": True} + return OkResponse() -@app.post("/items/{tableName}/*") +@app.post( + "/items/{tableName}/*", + name="Update item in table", + responses={ + 200: {"model": OkResponse, "description": "Item updated successfully"}, + 400: { + "model": ErrorResponse, + "description": "Some generic error happened during updating item", + }, + 403: { + "model": ErrorResponse, + "description": "Requesting this endpoint requires user access token", + }, + 404: { + "model": ErrorResponse, + "description": "Table or column not found", + }, + 409: { + "model": ErrorResponse, + "description": "Unique constraint violation", + }, + }, +) async def itemsUpdate( tableName: str, item: dict[str, str], @@ -252,11 +508,17 @@ async def itemsUpdate( ): table_info = connector.getTable(tableName) if not table_info: - return {"error": "Not found"} + return JSONResponse( + ErrorResponse(error="Table not found"), + status_code=status.HTTP_404_NOT_FOUND, + ) is_admin = check_if_admin_access_token(connector, access_token) if table_info["system"] and not is_admin: - return {"error": "Not allowed"} + return JSONResponse( + ErrorResponse(error="Not allowed"), + status_code=status.HTTP_403_FORBIDDEN, + ) user, group = get_user_by_access_token(connector, access_token) if not is_admin: @@ -264,41 +526,84 @@ async def itemsUpdate( connector, tableName, group.id if group else -1 ) if not allowedColumns: - return {"error": "Not allowed"} + return JSONResponse( + ErrorResponse(error="Not allowed"), + status_code=status.HTTP_403_FORBIDDEN, + ) elif len(allowedColumns) == 1 and allowedColumns[0] == "*": pass else: for column in item: if column not in allowedColumns: - return {"error": "Not allowed"} + return JSONResponse( + ErrorResponse(error="Not allowed"), + status_code=status.HTTP_403_FORBIDDEN, + ) try: connector.updateDataInTable( tableName, [ColumnUpdate(column=c, value=item[c]) for c in item], - [ColumnCondition(column=c, value=oldItem[c]) for c in oldItem], + [ + ColumnCondition(column=c, operator="eq", value=oldItem[c]) + for c in oldItem + ], + ) + except psycopg.errors.UniqueViolation: + return JSONResponse( + ErrorResponse(error="Unique violation"), + status_code=status.HTTP_409_CONFLICT, ) except psycopg.errors.UndefinedColumn: - return {"error": "Column not found"} + return JSONResponse( + ErrorResponse(error="Column not found"), + status_code=status.HTTP_404_NOT_FOUND, + ) except Exception as e: - return {"error": str(e)} + return JSONResponse( + ErrorResponse(error=str(e)), status_code=status.HTTP_400_BAD_REQUEST + ) - return {"ok": True} + return OkResponse() -@app.post("/items/{tableName}/-") +@app.post( + "/items/{tableName}/-", + name="Delete item from table", + responses={ + 200: {"model": OkResponse, "description": "Item deleted successfully"}, + 400: { + "model": ErrorResponse, + "description": "Some generic error happened during deleting item", + }, + 403: { + "model": ErrorResponse, + "description": "Requesting this endpoint requires user access token", + }, + 404: { + "model": ErrorResponse, + "description": "Table or column not found", + }, + }, +) async def itemsDelete( tableName: str, - deleteWhere: ItemDeletionDefinitionList, + deleteWhere: list[ColumnConditionCompat], access_token: str | None = Header(default=None), ): table_info = connector.getTable(tableName) if not table_info: - return {"error": "Not found"} + return JSONResponse( + ErrorResponse(error="Table not found"), + status_code=status.HTTP_404_NOT_FOUND, + ) is_admin = check_if_admin_access_token(connector, access_token) if table_info["system"] and not is_admin: - return {"error": "Not allowed"} + return JSONResponse( + ErrorResponse(error="Not allowed"), + status_code=status.HTTP_403_FORBIDDEN, + ) user, group = get_user_by_access_token(connector, access_token) if not is_admin: @@ -306,27 +611,44 @@ async def itemsDelete( connector, tableName, group.id if group else -1 ) if not allowedColumns: - return {"error": "Not allowed"} + return JSONResponse( + ErrorResponse(error="Not allowed"), + status_code=status.HTTP_403_FORBIDDEN, + ) elif len(allowedColumns) == 1 and allowedColumns[0] == "*": pass else: - return {"error": "Not allowed"} + return JSONResponse( + ErrorResponse(error="Not allowed"), + status_code=status.HTTP_403_FORBIDDEN, + ) try: connector.deleteFromTable( tableName, - [ - ColumnCondition(where.name, where.value, where.isString, where.isLike) - for where in deleteWhere.defs - ], + [ColumnCondition(dw.column, dw.operator, dw.value) for dw in deleteWhere], ) except Exception as e: - return {"error": str(e)} + return JSONResponse( + ErrorResponse(error=str(e)), status_code=status.HTTP_400_BAD_REQUEST + ) - return {"ok": True} + return OkResponse() -@app.get("/assets/{fid}") +@app.get( + "/assets/{fid}", + name="Get asset", + responses={ + 200: { + "description": "Asset found", + "content": {"application/octet-stream": {}}, + }, + 404: { + "description": "Asset not found", + }, + }, +) async def getAsset(fid: str, access_token: str | None = Header(default=None)): asset = get_asset(connector, access_token, fid) if not asset: @@ -349,14 +671,38 @@ async def getAsset(fid: str, access_token: str | None = Header(default=None)): response.release_conn() -@app.post("/assets/+") +@app.post( + "/assets/+", + name="Put asset", + responses={ + 200: { + "model": CreateAssetResponse, + "description": "Asset created successfully", + }, + 400: { + "model": ErrorResponse, + "description": "Some generic error happened during creating asset", + }, + 403: { + "model": ErrorResponse, + "description": "Requesting this endpoint requires user access token", + }, + 500: { + "model": ErrorResponse, + "description": "Failed put asset into storage", + }, + }, +) async def createAsset( asset: UploadFile, access_token: str | None = Header(default=None), ): user, _ = get_user_by_access_token(connector, access_token) if not user: - return {"error": "Not allowed"} + return JSONResponse( + ErrorResponse(error="Not allowed"), + status_code=status.HTTP_403_FORBIDDEN, + ) filename = asset.filename if not filename: @@ -372,10 +718,19 @@ async def createAsset( ), length=asset.size, ) + if not result: + return JSONResponse( + ErrorResponse(error="Failed put asset into storage"), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) if not create_asset(connector, filename, "", str(result.version_id)): - return {"error": "Failed to create asset"} - return {"ok": True, "fid": result.version_id} + return JSONResponse( + ErrorResponse(error="Failed to create asset"), + status_code=status.HTTP_400_BAD_REQUEST, + ) + + return CreateAssetResponse(fid=result.version_id) if __name__ == "__main__": diff --git a/db_addendum.py b/db_addendum.py new file mode 100644 index 0000000..8b002fe --- /dev/null +++ b/db_addendum.py @@ -0,0 +1,26 @@ +from psycopg.sql import SQL, Identifier, Literal, Composed +from based.columns import ( + IntegerColumnDefinition, +) + + +class UserRefColumnDefinition(IntegerColumnDefinition): + def __init__(self, name: str): + super().__init__(name) + + def sql(self): + return SQL("{} INTEGER NOT NULL").format(Identifier(self.name)) + + def serialize(self): + return f"{self.name}:asset" + + +class AssetRefColumnDefinition(IntegerColumnDefinition): + def __init__(self, name: str): + super().__init__(name) + + def sql(self): + return SQL("{} INTEGER NOT NULL").format(Identifier(self.name)) + + def serialize(self): + return f"{self.name}:asset" diff --git a/db_models.py b/db_models.py index ae9bc88..e56ade1 100644 --- a/db_models.py +++ b/db_models.py @@ -3,7 +3,6 @@ from based.columns import ( PrimarySerialColumnDefinition, TextColumnDefinition, IntegerColumnDefinition, - make_column_unique, ) from pydantic import BaseModel @@ -18,7 +17,7 @@ class AccessType(enum.Enum): META_INFO_TABLE_NAME = "meta_info" META_INFO_TABLE_SCHEMA = [ PrimarySerialColumnDefinition("id"), - make_column_unique(TextColumnDefinition("name")), + TextColumnDefinition("name", unique=True), TextColumnDefinition("value"), TextColumnDefinition("allowed_columns", default="*"), ] @@ -34,7 +33,7 @@ class MetaInfo(BaseModel): USER_GROUP_TABLE_NAME = "user_group" USER_GROUP_TABLE_SCHEMA = [ PrimarySerialColumnDefinition("id"), - make_column_unique(TextColumnDefinition("name")), + TextColumnDefinition("name", unique=True), TextColumnDefinition("description", default=""), ] @@ -48,7 +47,7 @@ class UserGroup(BaseModel): USERS_TABLE_NAME = "users" USERS_TABLE_SCHEMA = [ PrimarySerialColumnDefinition("id"), - make_column_unique(TextColumnDefinition("username")), + TextColumnDefinition("username", unique=True), TextColumnDefinition("password"), TextColumnDefinition("access_token"), ] diff --git a/dba.py b/dba.py index b3e5c15..f96382b 100644 --- a/dba.py +++ b/dba.py @@ -9,17 +9,21 @@ logger = logging.getLogger(__name__) def bootstrapDB(conn: DBConnector): if not conn.tableExists(META_INFO_TABLE_NAME): + logger.info("Creating meta info table") conn.createTable( META_INFO_TABLE_NAME, META_INFO_TABLE_SCHEMA, system=True, hidden=True ) if not conn.tableExists(USER_GROUP_TABLE_NAME): + logger.info("Creating user group table") conn.createTable(USER_GROUP_TABLE_NAME, USER_GROUP_TABLE_SCHEMA, system=True) if not conn.tableExists(USERS_TABLE_NAME): + logger.info("Creating users table") conn.createTable(USERS_TABLE_NAME, USERS_TABLE_SCHEMA, system=True) if not conn.tableExists(USER_IN_USER_GROUP_JOIN_TABLE_NAME): + logger.info("Creating user in user group join table") conn.createTable( USER_IN_USER_GROUP_JOIN_TABLE_NAME, USER_IN_USER_GROUP_JOIN_TABLE_SCHEMA, @@ -27,6 +31,7 @@ def bootstrapDB(conn: DBConnector): ) if not conn.tableExists(TABLE_ACCESS_TABLE_NAME): + logger.info("Creating table access table") conn.createTable( TABLE_ACCESS_TABLE_NAME, TABLE_ACCESS_TABLE_SCHEMA, @@ -34,6 +39,7 @@ def bootstrapDB(conn: DBConnector): ) if not conn.tableExists(ASSETS_TABLE_NAME): + logger.info("Creating assets table") conn.createTable( ASSETS_TABLE_NAME, ASSETS_TABLE_SCHEMA, @@ -41,6 +47,7 @@ def bootstrapDB(conn: DBConnector): ) if not conn.tableExists(ASSET_ACCESS_TABLE_NAME): + logger.info("Creating asset access table") conn.createTable( ASSET_ACCESS_TABLE_NAME, ASSET_ACCESS_TABLE_SCHEMA, @@ -50,6 +57,7 @@ def bootstrapDB(conn: DBConnector): meta = get_metadata(conn, "admin_created") testAdminCreated = meta and meta.value == "yes" if not testAdminCreated: + logger.info("Creating admin user and group") create_user(conn, "admin", "admin") create_group(conn, "admin") @@ -72,7 +80,7 @@ def add_metadata(conn: DBConnector, name: str, value: str): def get_metadata(conn: DBConnector, name: str): try: metadata = conn.filterFromTable( - META_INFO_TABLE_NAME, ["*"], [ColumnCondition("name", name)] + META_INFO_TABLE_NAME, ["*"], [ColumnCondition("name", "eq", name)] ) if len(metadata) == 0: logger.warning(f"Metadata {name} not found") @@ -106,7 +114,7 @@ def update_user(conn: DBConnector, id: int, password: str, access_token: str): ColumnUpdate("access_token", access_token), ], [ - ColumnCondition("id", id), + ColumnCondition("id", "eq", id), ], ) return True, None @@ -118,7 +126,7 @@ def update_user(conn: DBConnector, id: int, password: str, access_token: str): def get_user_by_username(conn: DBConnector, username: str): try: users = conn.filterFromTable( - USERS_TABLE_NAME, ["*"], [ColumnCondition("username", username)] + USERS_TABLE_NAME, ["*"], [ColumnCondition("username", "eq", username)] ) if len(users) == 0: logger.warning(f"User {username} not found") @@ -132,7 +140,7 @@ def get_user_by_username(conn: DBConnector, username: str): def get_user_by_id(conn: DBConnector, user_id: int): try: users = conn.filterFromTable( - USERS_TABLE_NAME, ["*"], [ColumnCondition("id", user_id)] + USERS_TABLE_NAME, ["*"], [ColumnCondition("id", "eq", user_id)] ) if len(users) == 0: logger.warning(f"User with id {user_id} not found") @@ -146,7 +154,9 @@ def get_user_by_id(conn: DBConnector, user_id: int): def get_user_by_access_token(conn: DBConnector, access_token: str | None): try: users = conn.filterFromTable( - USERS_TABLE_NAME, ["*"], [ColumnCondition("access_token", access_token)] + USERS_TABLE_NAME, + ["*"], + [ColumnCondition("access_token", "eq", access_token)], ) if len(users) == 0: logger.warning("Invalid access token") @@ -168,8 +178,8 @@ def check_user(conn: DBConnector, username: str, password: str): USERS_TABLE_NAME, ["*"], [ - ColumnCondition("username", username), - ColumnCondition("password", hashedPwd), + ColumnCondition("username", "eq", username), + ColumnCondition("password", "eq", hashedPwd), ], ) if len(user) == 0: @@ -195,7 +205,7 @@ def create_group(conn: DBConnector, name: str, description: str = ""): def get_group_by_name(conn: DBConnector, name: str): try: groups = conn.filterFromTable( - USER_GROUP_TABLE_NAME, ["*"], [ColumnCondition("name", name)] + USER_GROUP_TABLE_NAME, ["*"], [ColumnCondition("name", "eq", name)] ) if len(groups) == 0: logger.warning(f"Group {name} not found") @@ -209,7 +219,7 @@ def get_group_by_name(conn: DBConnector, name: str): def get_group_by_id(conn: DBConnector, group_id: int): try: groups = conn.filterFromTable( - USER_GROUP_TABLE_NAME, ["*"], [ColumnCondition("id", group_id)] + USER_GROUP_TABLE_NAME, ["*"], [ColumnCondition("id", "eq", group_id)] ) if len(groups) == 0: logger.warning(f"Group with id {group_id} not found") @@ -226,7 +236,7 @@ def set_user_group(conn: DBConnector, user_id: int, group_id: int): USER_IN_USER_GROUP_JOIN_TABLE_NAME, ["*"], [ - ColumnCondition("user_id", user_id), + ColumnCondition("user_id", "eq", user_id), ], ): conn.insertIntoTable( @@ -240,7 +250,7 @@ def set_user_group(conn: DBConnector, user_id: int, group_id: int): ColumnUpdate("user_group_id", group_id), ], [ - ColumnCondition("user_id", user_id), + ColumnCondition("user_id", "eq", user_id), ], ) return True, None @@ -254,7 +264,7 @@ def get_user_group(conn: DBConnector, user_id: int): grp_usr_joint = conn.filterFromTable( USER_IN_USER_GROUP_JOIN_TABLE_NAME, ["*"], - [ColumnCondition("user_id", user_id)], + [ColumnCondition("user_id", "eq", user_id)], ) if len(grp_usr_joint) == 0: logger.warning(f"User with id {user_id} not found, so no group") @@ -272,7 +282,7 @@ def get_group_users(conn: DBConnector, group_id: int) -> list[User]: users = conn.filterFromTable( USER_IN_USER_GROUP_JOIN_TABLE_NAME, ["*"], - [ColumnCondition("user_group_id", group_id)], + [ColumnCondition("user_group_id", "eq", group_id)], ) return [*map(User.parse_obj, users)] except Exception as e: @@ -321,8 +331,8 @@ def get_table_access_level( TABLE_ACCESS_TABLE_NAME, ["*"], [ - ColumnCondition("table_name", table_name), - ColumnCondition("user_group_id", user_group.id), + ColumnCondition("table_name", "eq", table_name), + ColumnCondition("user_group_id", "eq", user_group.id), ], ) if not access: @@ -349,8 +359,8 @@ def get_allowed_columns_for_group( TABLE_ACCESS_TABLE_NAME, ["*"], [ - ColumnCondition("table_name", table_name), - ColumnCondition("user_group_id", group_id), + ColumnCondition("table_name", "eq", table_name), + ColumnCondition("user_group_id", "eq", group_id), ], ) if not allowed_columns: @@ -400,7 +410,7 @@ def create_asset(conn: DBConnector, name: str, description: str, fid: str): }, ) # TODO: add asset access - # TODO: add asset to seaweedfs + # TODO: add asset to minio return True except Exception as e: logger.exception(e) @@ -409,9 +419,9 @@ def create_asset(conn: DBConnector, name: str, description: str, fid: str): def remove_asset(conn: DBConnector, token: str | None, asset_id: int): try: - conn.deleteFromTable(ASSETS_TABLE_NAME, [ColumnCondition("id", asset_id)]) + conn.deleteFromTable(ASSETS_TABLE_NAME, [ColumnCondition("id", "eq", asset_id)]) # TODO: remove asset access - # TODO: remove asset from seaweedfs + # TODO: remove asset from minio return True except Exception as e: logger.exception(e) @@ -422,7 +432,7 @@ def get_asset(conn: DBConnector, token: str | None, fid: str): try: user, group = get_user_by_access_token(conn, token) assets = conn.filterFromTable( - ASSETS_TABLE_NAME, ["*"], [ColumnCondition("fid", fid)] + ASSETS_TABLE_NAME, ["*"], [ColumnCondition("fid", "eq", fid)] ) print(assets) if len(assets) == 0: @@ -453,7 +463,9 @@ def create_asset_access(conn: DBConnector, asset_id: int, user_group_id: int): def get_asset_access(conn: DBConnector, asset_id: int): try: access = conn.filterFromTable( - ASSET_ACCESS_TABLE_NAME, ["*"], [ColumnCondition("asset_id", asset_id)] + ASSET_ACCESS_TABLE_NAME, + ["*"], + [ColumnCondition("asset_id", "eq", asset_id)], ) if not access: return AccessType.NONE diff --git a/models.py b/models.py index af42207..f31b05d 100644 --- a/models.py +++ b/models.py @@ -1,6 +1,6 @@ from typing import Any -from uuid import UUID from pydantic import BaseModel +from based.db import CONDITION_OPERATORS class AuthModel(BaseModel): @@ -9,11 +9,12 @@ class AuthModel(BaseModel): class ItemsFieldSelectorList(BaseModel): - fields: list[str] = [] + fields: list[str] | None = [] -class ColumnsDefinitionList(BaseModel): - columns: list[str] +class CreateUserDefinition(BaseModel): + username: str + password: str class UserDefinition(BaseModel): @@ -23,28 +24,42 @@ class UserDefinition(BaseModel): access_token: str | None = None -class ColumnDefinition(BaseModel): - name: str +class UserUpdateDefinition(BaseModel): + user_id: int + password: str + access_token: str + + +class ColumnConditionCompat(BaseModel): + column: str + operator: CONDITION_OPERATORS value: Any - isString: bool = False - isLike: bool = True - - -class ItemDeletionDefinitionList(BaseModel): - defs: list[ColumnDefinition] class TableDefinition(BaseModel): - table_id: UUID + table_id: str table_name: str columns: str system: bool hidden: bool -class TableListDefinition(BaseModel): - tables: list[TableDefinition] +class OkResponse(BaseModel): + ok: bool = True -class ErrorResponseDefinition(BaseModel): +class ErrorResponse(BaseModel): error: str + + +class AccessTokenResponse(BaseModel): + access_token: str + + +class TableItemsResponse(BaseModel): + items: list[dict[str, Any]] + + +class CreateAssetResponse(BaseModel): + ok: bool = True + fid: str diff --git a/utils.py b/utils.py index 88e1dd4..105d000 100644 --- a/utils.py +++ b/utils.py @@ -1,17 +1,12 @@ from based.db import DBConnector from based.columns import ( ColumnDefinition, - make_column_unique, PrimarySerialColumnDefinition, - PrimaryUUIDColumnDefinition, TextColumnDefinition, - BigintColumnDefinition, BooleanColumnDefinition, - DateColumnDefinition, TimestampColumnDefinition, DoubleColumnDefinition, IntegerColumnDefinition, - UUIDColumnDefinition, ) from dba import get_user_by_access_token @@ -34,82 +29,40 @@ def get_column_from_definition(definition: str) -> ColumnDefinition | None: case [name, "serial", "primary"]: return PrimarySerialColumnDefinition(name) - case [name, "uuid", "primary"]: - return PrimaryUUIDColumnDefinition(name) + case [name, "str", *rest]: + is_unique = "unique" in rest + has_default = "default" in rest + td = TextColumnDefinition(name, unique=is_unique) + td.has_default = has_default + return td - case [name, "str"]: - return TextColumnDefinition(name) - case [name, "str", "unique"]: - return make_column_unique(TextColumnDefinition(name)) - case [name, "str", "default", default]: - return TextColumnDefinition(name, default=default) - case [name, "str", "default", default, "unique"]: - return make_column_unique(TextColumnDefinition(name, default=default)) + case [name, "bool", *rest]: + is_unique = "unique" in rest + has_default = "default" in rest + td = BooleanColumnDefinition(name, unique=is_unique) + td.has_default = has_default + return td - case [name, "bigint"]: - return BigintColumnDefinition(name) - case [name, "bigint", "unique"]: - return make_column_unique(BigintColumnDefinition(name)) - case [name, "bigint", "default", default]: - return BigintColumnDefinition(name, default=int(default)) - case [name, "bigint", "default", default, "unique"]: - return make_column_unique( - BigintColumnDefinition(name, default=int(default)) - ) + case [name, "datetime", *rest]: + is_unique = "unique" in rest + has_default = "default" in rest + td = TimestampColumnDefinition(name, unique=is_unique) + td.has_default = has_default + return td - case [name, "bool"]: - return BooleanColumnDefinition(name) - case [name, "bool", "unique"]: - return make_column_unique(BooleanColumnDefinition(name)) - case [name, "bool", "default", default]: - return BooleanColumnDefinition(name, default=bool(default)) - case [name, "bool", "default", default, "unique"]: - return make_column_unique( - BooleanColumnDefinition(name, default=bool(default)) - ) + case [name, "float", *rest]: + is_unique = "unique" in rest + has_default = "default" in rest + td = DoubleColumnDefinition(name, unique=is_unique) + td.has_default = has_default + return td - case [name, "date"]: - return DateColumnDefinition(name) - case [name, "date", "unique"]: - return make_column_unique(DateColumnDefinition(name)) - # TODO: Add default value for date - - case [name, "datetime"]: - return TimestampColumnDefinition(name) - case [name, "datetime", "unique"]: - return make_column_unique(TimestampColumnDefinition(name)) - # TODO: Add default value for timestamp - - case [name, "float"]: - return DoubleColumnDefinition(name) - case [name, "float", "unique"]: - return make_column_unique(DoubleColumnDefinition(name)) - case [name, "float", "default", default]: - return DoubleColumnDefinition(name, default=float(default)) - case [name, "float", "default", default, "unique"]: - return make_column_unique( - DoubleColumnDefinition(name, default=float(default)) - ) - - case [name, "int"]: - return IntegerColumnDefinition(name) - case [name, "int", "unique"]: - return make_column_unique(IntegerColumnDefinition(name)) - case [name, "int", "default", default]: - return IntegerColumnDefinition(name, default=int(default)) - case [name, "int", "default", default, "unique"]: - return make_column_unique( - IntegerColumnDefinition(name, default=int(default)) - ) - - case [name, "uuid"]: - return UUIDColumnDefinition(name) - case [name, "uuid", "unique"]: - return make_column_unique(UUIDColumnDefinition(name)) - case [name, "uuid", "default", default]: - return UUIDColumnDefinition(name, default=default) - case [name, "uuid", "default", default, "unique"]: - return make_column_unique(UUIDColumnDefinition(name, default=default)) + case [name, "int", *rest]: + is_unique = "unique" in rest + has_default = "default" in rest + td = IntegerColumnDefinition(name, unique=is_unique) + td.has_default = has_default + return td return None