diff --git a/Makefile b/Makefile index d57ff1c..53e4e87 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ check: # TODO separate into production build setup python3 -m flake8 . run: setup - gunicorn app:app -w 2 --reload --threads 2 -b 0.0.0.0:3001 + uvicorn app:app --reload --workers 2 --host 0.0.0.0 --port 3001 clean: find . -type f -name ‘*.pyc’ -delete \ No newline at end of file diff --git a/README.md b/README.md index ab68685..b588b2e 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,9 @@ See [AA-DR Portal README](https://github.com/castlepointanime/portal/blob/main/R `/utilities` Miscellaneous small python modules -### Flasgger +### Apidocs -[Flasgger](https://github.com/flasgger/flasgger) is a python module that allows for separate openapi files in multiple places. Flasgger can also can read these files and validate flask requests with the spec. This is added to make validation easier and to constantly keep the api documentation up to date. - -To see the apidocs, view `http://localhost:3001/apidocs` +FastAPI auto-generates API docs. To see the apidocs, view `http://localhost:3001/docs` ### Mypy/Flake8 diff --git a/app.py b/app.py index 4544455..9930eb3 100644 --- a/app.py +++ b/app.py @@ -1,86 +1,52 @@ -from flask import Flask, Response, request -from flask_cors import CORS -from flask_cognito import CognitoAuth -from flasgger import Swagger from controllers import ContractController, MeController, HealthController -from flask_restful import Api +from fastapi import FastAPI, Request, Response, status +from fastapi.responses import JSONResponse from time import strftime import logging -from utilities.types import JSONDict from config.env import COGNITO_REGION, COGNITO_USERPOOL_ID, COGNITO_APP_CLIENT_ID -from managers import MeManager -from http import HTTPStatus -from utilities.types import FlaskResponseType import traceback +import uvicorn +from fastapi_cloudauth.cognito import Cognito +from starlette.middleware.base import _StreamingResponse +from typing import Awaitable, Callable -app = Flask(__name__) -app.config.update({ - 'COGNITO_REGION': COGNITO_REGION, - 'COGNITO_USERPOOL_ID': COGNITO_USERPOOL_ID, - 'COGNITO_APP_CLIENT_ID': COGNITO_APP_CLIENT_ID, - - # optional - 'COGNITO_CHECK_TOKEN_EXPIRATION': True -}) - -app.config['SWAGGER'] = { - 'title': 'AADR Backend API' -} - - -cogauth = CognitoAuth(app) -cogauth.init_app(app) -CORS(app) -Swagger(app) -api = Api(app) +app = FastAPI() +auth = Cognito( + region=COGNITO_REGION, + userPoolId=COGNITO_USERPOOL_ID, + client_id=COGNITO_APP_CLIENT_ID +) logging.getLogger().setLevel(logging.INFO) -api.add_resource(ContractController, '/contract') -api.add_resource(MeController, "/me") -api.add_resource(HealthController, "/health") - - -@cogauth.identity_handler -def lookup_cognito_user(payload: JSONDict) -> str: - """Look up user in our database from Cognito JWT payload.""" - assert 'sub' in payload, "Invalid Cognito JWT payload" - user_id = payload['sub'] - - me_manager = MeManager() - user = me_manager.get_user_from_db(user_id) - - # Add database information to payload - payload['database'] = user - - # ID tokens contain 'cognito:username' in payload instead of 'username' - username = None - if "cognito:username" in payload: - username = payload['cognito:username'] - elif "username" in payload: - username = payload['username'] - - assert type(username) == str, "Invalid username" - return username +app.include_router(ContractController(auth).router) +app.include_router(MeController(auth).router) +app.include_router(HealthController(auth).router) -@app.after_request -def after_request(response: Response) -> Response: +@app.middleware("http") +async def after_request(request: Request, call_next: Callable[..., Awaitable[_StreamingResponse]]) -> Response: + response: Response = await call_next(request) timestamp = strftime('[%Y-%b-%d %H:%M]') # TODO this is defined in multiple spots. Make robust - logging.info('%s %s %s %s %s %s', timestamp, request.remote_addr, request.method, request.scheme, request.full_path, response.status) + assert request.client, "Missing header data in request. No client information." + logging.info('%s %s %s %s %s %s', timestamp, request.client.host, request.method, request.scope['type'], request.url, response.status_code) return response -# @app.errorhandler(Exception) # type: ignore[type-var] -def exceptions(e: Exception) -> FlaskResponseType: +@app.exception_handler(Exception) +def exceptions(request: Request, e: Exception) -> JSONResponse: tb = traceback.format_exc() timestamp = strftime('[%Y-%b-%d %H:%M]') - logging.error('%s %s %s %s %s 5xx INTERNAL SERVER ERROR\n%s', timestamp, request.remote_addr, request.method, request.scheme, request.full_path, tb) + assert request.client, "Missing header data in request. No client information." + logging.error('%s %s %s %s %s 5xx INTERNAL SERVER ERROR\n%s', timestamp, request.client.host, request.method, request.scope['type'], request.url, tb) logging.error(e) - return "Internal server error", HTTPStatus.INTERNAL_SERVER_ERROR + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content=None + ) if __name__ == "__main__": logging.getLogger().setLevel(logging.DEBUG) - app.run(debug=True, host="0.0.0.0", port=3001) + uvicorn.run(app, host="0.0.0.0", port=3001) diff --git a/config.json b/config.json index 1b73a8a..7962f8e 100644 --- a/config.json +++ b/config.json @@ -1,7 +1,9 @@ { "contract_limits": { "max_additional_chairs": 2, - "max_helpers": 3 + "max_helpers": 3, + "phone_number_max": 10000000000, + "phone_number_min": 99999999999 }, "docusign": { "authorization_server": "account-d.docusign.com" diff --git a/controllers/__init__.py b/controllers/__init__.py index 950af48..6282300 100644 --- a/controllers/__init__.py +++ b/controllers/__init__.py @@ -1,5 +1,4 @@ from .contract import ContractController from .base_controller import BaseController -from .swagger import * from .health import HealthController from .me import MeController \ No newline at end of file diff --git a/controllers/base_controller.py b/controllers/base_controller.py index 038b8f2..e6cb36e 100644 --- a/controllers/base_controller.py +++ b/controllers/base_controller.py @@ -1,58 +1,18 @@ -from flask import Response, abort -from http import HTTPStatus -from flask_restful import Resource -from flask import request -from flasgger import validate -import json -from typing import Dict, Any, Union -from utilities.types import JSONDict -from jsonschema.exceptions import ValidationError import logging -from flask_cognito import cognito_auth_required, current_cognito_jwt +from fastapi import APIRouter from time import strftime +from fastapi_cloudauth.cognito import Cognito +from fastapi import Request -class BaseController(Resource): # type: ignore[no-any-unimported] +class BaseController: - @classmethod - def log_debug(cls, msg: str) -> None: - timestamp = strftime('[%Y-%b-%d %H:%M]') - logging.debug('%s %s %s %s %s %s', timestamp, request.remote_addr, request.method, request.scheme, request.full_path, msg) + def __init__(self, auth: Cognito): # type: ignore[no-any-unimported] + self.router = APIRouter() + self.auth = auth @classmethod - def get_request_data(cls, swagger_data: Union[str, JSONDict], swagger_object_id: str) -> Dict[str, Any]: - """ - Gets and verifies request data. - It is preferred to use a .yaml str filepath for swagger_data, - but for dynamic swagger API's based on configs, use a dictionary of the spec - """ - data = request.get_json() - assert type(data) == dict, "Invalid data in request" - cls.log_debug(json.dumps(data)) - if type(swagger_data) is dict: - validate(data, swagger_object_id, specs=swagger_data, validation_error_handler=cls.error_handler) - else: - validate(data, swagger_object_id, swagger_data, validation_error_handler=cls.error_handler) - return data - - @classmethod - def abort_request(cls, message: str, status: int) -> None: - abort(Response(json.dumps({'error': message}), status=status)) - - @classmethod - def error_handler(cls, err: ValidationError, data: JSONDict, schema: JSONDict) -> None: - """ - Error handler for flasgger - """ - error_message = str(err.message) - cls.log_debug(error_message) - cls.abort_request(error_message, HTTPStatus.BAD_REQUEST) - - @classmethod - @cognito_auth_required - def verify_id_token(cls) -> None: - """ - Returns 400 if header token is not id - """ - if current_cognito_jwt['token_use'] != "id": - cls.abort_request("Header must contain an ID token", HTTPStatus.BAD_REQUEST) + def log_debug(cls, msg: str, request: Request) -> None: + timestamp = strftime('[%Y-%b-%d %H:%M]') + assert request.client, "Missing header data in request. No client information." + logging.debug('%s %s %s %s %s %s', timestamp, request.client.host, request.method, request.scope['type'], request.url, msg) diff --git a/controllers/contract.py b/controllers/contract.py index 9597648..6e59d4d 100644 --- a/controllers/contract.py +++ b/controllers/contract.py @@ -1,36 +1,63 @@ -from flask_cognito import cognito_auth_required, current_cognito_jwt, current_user from managers import ContractManager -from flasgger import swag_from from .base_controller import BaseController -from utilities.types import FlaskResponseType -from utilities import FlaskResponses, NoApproverException -from .swagger.contract.post import contract_post_schema -from utilities.types import JSONDict +from utilities import NoApproverException +from utilities.types import HelperModel from typing import Optional +from fastapi_cloudauth.cognito import Cognito +from fastapi_cloudauth.cognito import CognitoClaims +from utilities.auth import get_current_user +from fastapi import status, Depends, Response, HTTPException +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field +from utilities.types.fields import phone_number +from config import Config +from typing import List +from database.users import UsersDB + +config = Config() + + +class PostItem(BaseModel): + artist_phone_number: int = phone_number("artistPhoneNumber") + helpers: Optional[List[HelperModel]] = Field(alias="helpers", min_length=1, max_length=config.get_contract_limit("max_helpers")) + num_additional_chairs: int = Field(alias="numAdditionalChairs", le=config.get_contract_limit("max_additional_chairs"), ge=0, examples=['2']) + + +class PostResponseItem(BaseModel): + contractId: int = 0 class ContractController(BaseController): - @cognito_auth_required - @swag_from(contract_post_schema) - def post(self) -> FlaskResponseType: - data = self.get_request_data(contract_post_schema, "ContractData") + def __init__(self, auth: Cognito): # type: ignore[no-any-unimported] + super().__init__(auth) + self.router.add_api_route("/contract", self.post, methods=["POST"], response_model=PostResponseItem) - user_db: Optional[JSONDict] = current_cognito_jwt['database'] - if user_db is None: - return FlaskResponses.bad_request("User needs to make an account") + async def post(self, item: PostItem, current_user: CognitoClaims = Depends(get_current_user)) -> Response: # type: ignore[no-any-unimported] + db = await UsersDB.get_user(current_user) + if not db: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='User needs to make an account' + ) try: - result = ContractManager().create_contract( - current_cognito_jwt['sub'], - contract_type=user_db['vendor_type'], - helpers=data.get('helpers'), - num_additional_chairs=data['numAdditionalChairs'], - signer_email=current_cognito_jwt['email'], # TODO assert that emails are verified - signer_name=str(current_user), - artist_phone_number=data['artistPhoneNumber'] + result = await ContractManager().create_contract( + current_user.sub, + contract_type=str(db.get("vendor_type")), + helpers=item.helpers, + num_additional_chairs=item.num_additional_chairs, + signer_email=current_user.email, # TODO assert that emails are verified + signer_name=current_user.username, + artist_phone_number=item.artist_phone_number # TODO this should be stored in AWS ) except NoApproverException: - return FlaskResponses.conflict("Cannot make contract since there is nobody to approve the contract.") + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail='Cannot make contract since there is nobody to approve the contract' + ) - return FlaskResponses.success(result) + return JSONResponse( + status_code=status.HTTP_200_OK, + content=result + ) diff --git a/controllers/health.py b/controllers/health.py index 81cae81..4770ba8 100644 --- a/controllers/health.py +++ b/controllers/health.py @@ -1,11 +1,17 @@ -from flasgger import swag_from from .base_controller import BaseController -from utilities.types import FlaskResponseType -from utilities import FlaskResponses +from fastapi import status, Response +from fastapi_cloudauth.cognito import Cognito +from fastapi.responses import JSONResponse class HealthController(BaseController): - @swag_from("swagger/health/get.yaml") - def get(self) -> FlaskResponseType: - return FlaskResponses().success("ok") + def __init__(self, auth: Cognito): # type: ignore[no-any-unimported] + super().__init__(auth) + self.router.add_api_route("/health", self.get, methods=["GET"], response_model=None) + + def get(self) -> Response: + return JSONResponse( + status_code=status.HTTP_200_OK, + content=None + ) diff --git a/controllers/me.py b/controllers/me.py index aadf70d..46f1891 100644 --- a/controllers/me.py +++ b/controllers/me.py @@ -1,30 +1,82 @@ -from flask_cognito import cognito_auth_required, current_user, current_cognito_jwt from .base_controller import BaseController -from utilities.types import FlaskResponseType -from utilities.flask_responses import FlaskResponses from managers import MeManager -from flasgger import swag_from +from fastapi import Response, status, Depends, HTTPException +from fastapi_cloudauth.cognito import Cognito +from fastapi.responses import JSONResponse +from utilities.auth import get_current_user +from fastapi_cloudauth.cognito import CognitoClaims +from pydantic import BaseModel, Field, EmailStr, UUID4 +from utilities.types.fields import VendorTypeEnum +from typing import List +from typing_extensions import TypedDict +import uuid +from database.users import UsersDB + + +class PostItem(BaseModel): + vendor_type: VendorTypeEnum = Field(alias="vendorType") + + +class PostResponseItem(BaseModel): + created: bool = True + + +class GetDatabaseModel(TypedDict, total=True): + _id: UUID4 + Group: str + Roles: List[str] + contracts: List[UUID4] + vendorType: VendorTypeEnum + + +class GetResponseItem(BaseModel): + email: EmailStr = "bob123@mail.com" + name: str = "Bob" + database: GetDatabaseModel = GetDatabaseModel( + Group="Customer", + _id=uuid.uuid4(), + Roles=["CanEditCustomer"], + contracts=[ + uuid.uuid4(), + ], + vendorType=VendorTypeEnum.artist + ) class MeController(BaseController): - ME_POST_SCHEMA = "swagger/me/post.yaml" + def __init__(self, auth: Cognito): # type: ignore[no-any-unimported] + super().__init__(auth) + self.router.add_api_route("/me", self.get, methods=["GET"], response_model=GetResponseItem) + self.router.add_api_route("/me", self.patch, methods=["PATCH"]) + self.router.add_api_route("/me", self.post, methods=["POST"], response_model=PostResponseItem) - @cognito_auth_required - @swag_from("swagger/me/get.yaml") - def get(self) -> FlaskResponseType: - result = MeManager().get_user(str(current_user), current_cognito_jwt) - return FlaskResponses().success(result) + async def get(self, current_user: CognitoClaims = Depends(get_current_user)) -> Response: # type: ignore[no-any-unimported] + result = await UsersDB.get_user(current_user) + return JSONResponse( + status_code=status.HTTP_200_OK, + content={ + "name": current_user.username, + "email": current_user.email, + "database": result + } + ) - @cognito_auth_required - def patch(self) -> FlaskResponseType: - return FlaskResponses().not_implemented_yet() # TODO + async def patch(self, current_user: CognitoClaims = Depends(get_current_user)) -> Response: # type: ignore[no-any-unimported] + # TODO + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail="Not implemented yet" + ) - @cognito_auth_required - @swag_from(ME_POST_SCHEMA) - def post(self) -> FlaskResponseType: - data = self.get_request_data(self.ME_POST_SCHEMA, "NewUserData") - ret = MeManager().create_user(current_cognito_jwt['sub'], data['vendorType']) + async def post(self, item: PostItem, current_user: CognitoClaims = Depends(get_current_user)) -> Response: # type: ignore[no-any-unimported] + ret: bool = await MeManager().create_user(current_user.sub, str(item.vendor_type)) if not ret: - return FlaskResponses().bad_request("Failed to make user") - return FlaskResponses().created_resource(ret) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Failed to make user" + ) + return JSONResponse( + status_code=status.HTTP_201_CREATED, + content={"created": ret} + ) diff --git a/controllers/swagger/__init__.py b/controllers/swagger/__init__.py deleted file mode 100644 index d3aea48..0000000 --- a/controllers/swagger/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .contract import * \ No newline at end of file diff --git a/controllers/swagger/contract/__init__.py b/controllers/swagger/contract/__init__.py deleted file mode 100644 index 79d1b48..0000000 --- a/controllers/swagger/contract/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .post import contract_post_schema diff --git a/controllers/swagger/contract/post.py b/controllers/swagger/contract/post.py deleted file mode 100644 index 374e3e9..0000000 --- a/controllers/swagger/contract/post.py +++ /dev/null @@ -1,141 +0,0 @@ -from config import Config -from utilities.rbac import Groups, Roles - -contract_post_schema = { - 'tags': [ - 'contract' - ], - 'parameters': [{ - 'name': 'make contract', - 'in': 'body', - 'required': True, - 'schema': { - 'id': 'ContractData', - 'type': 'object', - 'properties': { - 'numAdditionalChairs': { - 'type': 'integer', - 'minimum': 0, - 'maximum': Config().get_contract_limit('max_additional_chairs'), - 'example': 2 - }, - 'artistPhoneNumber': { - '$ref': '#/definitions/PhoneNumber' - }, - 'helpers': { - 'type': 'array', - 'maxItems': Config().get_contract_limit('max_helpers'), - 'items': { - '$ref': '#/definitions/Helper' - } - } - }, - 'required': [ - 'numAdditionalChairs', - 'artistPhoneNumber', - ] - } - }], - 'definitions': { - 'Group': { - 'type': 'string', - 'enum': Groups.get_all() - }, - 'Roles': { - 'type': 'string', - 'enum': Roles.get_all() - }, - 'UUID': { - 'type': 'string', - 'example': '94953e00-4bfe-482c-813b-8f6454500380', - 'minLength': 36, - 'maxLength': 36 - }, - 'VendorType': { - 'type': 'string', - 'enum': ['artist', 'dealer'] - }, - 'Helper': { - 'type': 'object', - 'properties': { - 'name': { - 'type': 'string' - }, - 'phoneNumber': { - '$ref': '#/definitions/PhoneNumber' - } - }, - }, - 'PhoneNumber': { - 'type': 'integer', - 'minimum': 10000000000, - 'maximum': 99999999999, - 'example': 11234567890 - }, - 'Error': { - 'type': 'object', - 'properties': { - 'error': { - 'type': 'string', - 'example': 'Error message' - } - } - }, - 'UnauthorizedError': { - 'type': 'object', - 'properties': { - 'error': { - 'type': 'string', - 'example': 'Error message' - }, - 'description': { - 'type': 'string', - 'example': 'Request does not contain a well-formed access token in the \"Authorization\" header beginning with \"Bearer\"' - } - } - } - }, - 'responses': { - '200': { - 'description': 'Successfully created contract', - 'schema': { - 'type': 'object', - 'properties': { - 'contractId': { - 'type': 'integer' - } - } - } - }, - '400': { - 'description': 'Failed to make contract', - 'schema': { - '$ref': '#/definitions/Error' - } - }, - '401': { - 'description': 'Unauthorized', - 'schema': { - '$ref': '#/definitions/UnauthorizedError' - } - }, - '404': { - 'description': 'Bad request', - 'schema': { - '$ref': '#/definitions/Error' - } - }, - '409': { - 'description': 'No staff exists to approve the contract', - 'schema': { - '$ref': '#/definitions/Error' - } - }, - '500': { - 'description': 'Internal server error', - 'schema': { - '$ref': '#/definitions/Error' - } - }, - } -} diff --git a/controllers/swagger/health/get.yaml b/controllers/swagger/health/get.yaml deleted file mode 100644 index a0ada8c..0000000 --- a/controllers/swagger/health/get.yaml +++ /dev/null @@ -1,13 +0,0 @@ -tags: - - health -responses: - 200: - description: Server is ok - schema: - type: string - example: ok - 500: - description: Internal server error - schema: - type: string - example: Internal server error \ No newline at end of file diff --git a/controllers/swagger/me/get.yaml b/controllers/swagger/me/get.yaml deleted file mode 100644 index cab3adf..0000000 --- a/controllers/swagger/me/get.yaml +++ /dev/null @@ -1,45 +0,0 @@ -tags: - - me -responses: - 200: - description: Successfully fetched profile data - schema: - type: object - properties: - name: - type: string - example: Bob - email: - type: string - example: bob123@mail.com - database: - type: object - properties: - _id: - $ref: '#/definitions/UUID' - Group: - $ref: '#/definitions/Group' - Roles: - $ref: '#/definitions/Roles' - vendorType: - $ref: '#/definitions/VendorType' - contracts: - type: array - items: - $ref: '#/definitions/UUID' - 400: - description: Failed to get profile data - schema: - $ref: '#/definitions/Error' - 401: - description: Unauthorized - schema: - $ref: '#/definitions/UnauthorizedError' - 404: - description: Bad request - schema: - $ref: '#/definitions/Error' - 500: - description: Internal server error - schema: - $ref: '#/definitions/Error' diff --git a/controllers/swagger/me/post.yaml b/controllers/swagger/me/post.yaml deleted file mode 100644 index 3179d82..0000000 --- a/controllers/swagger/me/post.yaml +++ /dev/null @@ -1,42 +0,0 @@ -tags: - - me -parameters: - - name: 'make profile' - in: body - required: True - schema: - id: NewUserData - type: object - properties: - vendorType: - $ref: '#/definitions/VendorType' - required: - - vendorType -definitions: - VendorType: - type: string - enum: [artist, dealer] - Error: - type: object - properties: - error: - type: string - example: 'Error message' -responses: - 201: - description: Successfully created new user - schema: - type: boolean - 400: - description: Failed to make user - schema: - $ref: '#/definitions/Error' - 404: - description: Bad request - schema: - $ref: '#/definitions/Error' - 500: - description: Internal server error - schema: - $ref: '#/definitions/Error' - diff --git a/database/base_db.py b/database/base_db.py index 1ee3873..edbd667 100644 --- a/database/base_db.py +++ b/database/base_db.py @@ -1,34 +1,23 @@ from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase, AsyncIOMotorCollection from config.env import MONGO_URI, MONGO_DB_NAME import logging -from pymongo import MongoClient -import pymongo.collection -import pymongo.database -from typing import Optional, Union, List +from typing import Optional, List from utilities.types import MongoMappingType, JSONDict class BaseDB: - async_client: Optional[AsyncIOMotorClient] = None # type: ignore[no-any-unimported] - client: Optional[MongoClient[MongoMappingType]] = None + client: Optional[AsyncIOMotorClient] = None # type: ignore[no-any-unimported] @classmethod - def _get_client_async(cls) -> AsyncIOMotorClient: # type: ignore[no-any-unimported] - if not cls.async_client: - cls.async_client = AsyncIOMotorClient(MONGO_URI) - cls._verify_connection(cls.async_client) - return cls.async_client - - @classmethod - def _get_client(cls) -> MongoClient[MongoMappingType]: + def _get_client(cls) -> AsyncIOMotorClient: # type: ignore[no-any-unimported] if not cls.client: - cls.client = MongoClient(MONGO_URI) + cls.client = AsyncIOMotorClient(MONGO_URI) cls._verify_connection(cls.client) return cls.client @classmethod - def _verify_connection(cls, client: Union[MongoClient[MongoMappingType], AsyncIOMotorClient]) -> None: # type: ignore[no-any-unimported] + def _verify_connection(cls, client: AsyncIOMotorClient) -> None: # type: ignore[no-any-unimported] try: client.server_info() except Exception as e: @@ -36,15 +25,12 @@ def _verify_connection(cls, client: Union[MongoClient[MongoMappingType], AsyncIO raise e @classmethod - def get_database_async(cls) -> AsyncIOMotorDatabase: # type: ignore[no-any-unimported] - return cls._get_client_async()[MONGO_DB_NAME] - - @classmethod - def get_database(cls) -> pymongo.database.Database[MongoMappingType]: + def get_database(cls) -> AsyncIOMotorDatabase: # type: ignore[no-any-unimported] return cls._get_client()[MONGO_DB_NAME] + # TODO this needs to be reworked @classmethod - def get_random(cls, collection: Union[pymongo.collection.Collection[MongoMappingType], AsyncIOMotorCollection], count: int, query: JSONDict) -> List[MongoMappingType]: # type: ignore[no-any-unimported] + async def get_random(cls, collection: AsyncIOMotorCollection, count: int, query: JSONDict) -> List[MongoMappingType]: # type: ignore[no-any-unimported] results = [] aggregate_query = [ @@ -52,6 +38,6 @@ def get_random(cls, collection: Union[pymongo.collection.Collection[MongoMapping {"$sample": {"size": count}} ] it = collection.aggregate(aggregate_query) - for doc in it: + async for doc in it: results.append(doc) return results diff --git a/database/users.py b/database/users.py index 959062d..72101c9 100644 --- a/database/users.py +++ b/database/users.py @@ -1,10 +1,10 @@ -from motor.motor_asyncio import AsyncIOMotorCollection -from .base_db import BaseDB from typing import Optional from utilities.types import JSONDict, MongoMappingType import pymongo from utilities.rbac import Roles, Groups import pymongo.results +from .base_db import BaseDB +from motor.motor_asyncio import AsyncIOMotorCollection class UsersDB(BaseDB): @@ -20,34 +20,33 @@ def _new_user(cls, _id: str, vendor_type: str) -> JSONDict: } @classmethod - def get_user(cls, uuid: str) -> Optional[MongoMappingType]: + async def get_user(cls, uuid: str) -> Optional[MongoMappingType]: query = {"_id": uuid} - return cls.get_collection().find_one(query) + result: Optional[MongoMappingType] = await cls.get_collection().find_one(query) + return result @classmethod - def create_user(cls, uuid: str, vendor_type: str) -> bool: + async def create_user(cls, uuid: str, vendor_type: str) -> bool: query = cls._new_user(uuid, vendor_type) # TODO catch error if user already exists - ret: pymongo.results.InsertOneResult = cls.get_collection().insert_one(query) + ret: pymongo.results.InsertOneResult = await cls.get_collection().insert_one(query) return ret.acknowledged @classmethod - def get_collection(cls) -> pymongo.collection.Collection[MongoMappingType]: + def get_collection(cls) -> AsyncIOMotorCollection: # type: ignore[no-any-unimported] return super().get_database()['users'] @classmethod - def get_collection_async(cls) -> AsyncIOMotorCollection: # type: ignore[no-any-unimported] - return super().get_database_async()['users'] - - @classmethod - def add_user_contract(cls, uuid: str, contract_id: str) -> pymongo.results.UpdateResult: - return cls.get_collection().update_one( + async def add_user_contract(cls, uuid: str, contract_id: str) -> pymongo.results.UpdateResult: + result: pymongo.results.UpdateResult = await cls.get_collection().update_one( {"_id": uuid}, {"$addToSet": {"contracts": contract_id}} ) + return result + # TODO this needs to be reworked @classmethod - def _get_random_reviewer(cls, role: str) -> Optional[MongoMappingType]: + async def _get_random_reviewer(cls, role: str) -> Optional[MongoMappingType]: query = { "$and": [ { @@ -62,16 +61,16 @@ def _get_random_reviewer(cls, role: str) -> Optional[MongoMappingType]: } ] } - results = cls.get_random(cls.get_collection(), 1, query) + results = await cls.get_random(cls.get_collection(), 1, query) if len(results) == 0: return None assert len(results) == 1 return results[0] @classmethod - def get_random_artist_reviewer(cls) -> Optional[MongoMappingType]: - return cls._get_random_reviewer(Roles.ARTIST_REVIEWER) + async def get_random_artist_reviewer(cls) -> Optional[MongoMappingType]: + return await cls._get_random_reviewer(Roles.ARTIST_REVIEWER) @classmethod - def get_random_dealer_reviewer(cls) -> Optional[MongoMappingType]: - return cls._get_random_reviewer(Roles.DEALER_REVIEWER) + async def get_random_dealer_reviewer(cls) -> Optional[MongoMappingType]: + return await cls._get_random_reviewer(Roles.DEALER_REVIEWER) diff --git a/managers/contract.py b/managers/contract.py index 29eec2d..e8faeac 100644 --- a/managers/contract.py +++ b/managers/contract.py @@ -1,28 +1,31 @@ from services.docusign import Docusign, ContractData from typing import Optional -from utilities.types import HelperData +from utilities.types import HelperModel from typing import Dict from database import UsersDB from utilities import NoApproverException +from typing import List class ContractManager(): - def create_contract(self, - user_id: str, - contract_type: str, - num_additional_chairs: int, - artist_phone_number: int, - signer_name: str, - signer_email: str, - helpers: Optional[HelperData]) -> Dict[str, str]: + async def create_contract( + self, + user_id: str, + contract_type: str, + num_additional_chairs: int, + artist_phone_number: int, + signer_name: str, + signer_email: str, + helpers: Optional[List[HelperModel]] + ) -> Dict[str, str]: # TODO need to discuss spec for dealer contract if contract_type == "dealer": raise NotImplementedError() # Randomly get an approver - approver = UsersDB.get_random_artist_reviewer() + approver = await UsersDB.get_random_artist_reviewer() if approver is None: raise NoApproverException() # TODO cannot do this to get approver. Need to grab from cognito DB @@ -48,7 +51,7 @@ def create_contract(self, contract_id = docusign.create_contract(data) - UsersDB().add_user_contract(user_id, contract_id) + await UsersDB().add_user_contract(user_id, contract_id) # TODO create task to update the approver's entry so he has a reference of the contract return {'contractId': contract_id} diff --git a/managers/me.py b/managers/me.py index c25adec..2cc0c8e 100644 --- a/managers/me.py +++ b/managers/me.py @@ -1,21 +1,7 @@ -from werkzeug.local import LocalProxy -from utilities.types import JSONDict -from typing import Dict, Optional from database import UsersDB -from utilities.types import MongoMappingType class MeManager(): - def get_user_from_db(cls, user_id: str) -> Optional[MongoMappingType]: - return UsersDB().get_user(user_id) - - def get_user(self, current_user: str, current_cognito_jwt: LocalProxy[JSONDict]) -> Dict[str, str]: - return { - 'name': str(current_user), - 'email': str(current_cognito_jwt['email']), - 'database': current_cognito_jwt['database'] - } - - def create_user(self, user_id: str, vendor_type: str) -> bool: - return UsersDB.create_user(user_id, vendor_type) + async def create_user(self, user_id: str, vendor_type: str) -> bool: + return await UsersDB.create_user(user_id, vendor_type) diff --git a/mypy.ini b/mypy.ini index 230b572..e9dda61 100644 --- a/mypy.ini +++ b/mypy.ini @@ -16,19 +16,13 @@ show_error_codes = True disallow_any_unimported = True no_implicit_optional = True -[mypy-flasgger] -ignore_missing_imports = True - -[mypy-flask_restful] -ignore_missing_imports = True - [mypy-docusign_esign] ignore_missing_imports = True [mypy-docusign_esign.client.api_exception] ignore_missing_imports = True -[mypy-flask_cognito] +[mypy-fastapi_cloudauth.cognito] ignore_missing_imports = True [mypy-motor.motor_asyncio] diff --git a/requirements.txt b/requirements.txt index 1c26b41..bc1e1d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,14 +12,12 @@ cryptography==41.0.2 dnspython==2.4.0 docusign-esign==3.23.0 ecdsa==0.18.0 +email-validator==2.0.0.post2 exceptiongroup==1.1.2 +fastapi==0.103.1 +fastapi-cloudauth==0.4.3 flake8==6.0.0 -flasgger==0.9.7.1 -Flask==2.3.2 -Flask-Cognito==1.18 -Flask-Cors==4.0.0 -Flask-RESTful==0.3.10 -gunicorn==21.2.0 +uvicorn==0.23.2 h11==0.14.0 httpcore==0.17.3 idna==3.4 @@ -53,7 +51,6 @@ rsa==4.9 six==1.16.0 sniffio==1.3.0 tomli==2.0.1 -types-Flask-Cors==4.0.0.1 types-jsonschema==4.17.0.10 typing_extensions==4.7.1 urllib3==2.0.4 diff --git a/services/docusign/contract_data.py b/services/docusign/contract_data.py index ae9b3b4..74f582d 100644 --- a/services/docusign/contract_data.py +++ b/services/docusign/contract_data.py @@ -1,6 +1,6 @@ from docusign_esign import EnvelopeDefinition, TemplateRole, Tabs, Text, Number from config.env import CONTRACT_TEMPLATE_ID -from utilities.types import HelperData +from utilities.types import HelperModel from dataclasses import dataclass import datetime from typing import Dict, List, Optional @@ -13,7 +13,7 @@ class ContractData: num_additional_chairs: int artist_phone_number: int - helpers: Optional[HelperData] + helpers: Optional[List[HelperModel]] signer_name: str signer_email: str approver_name: str @@ -47,9 +47,8 @@ def generate_envelope(self) -> EnvelopeDefinition: # type: ignore[no-any-unimpo assert len(self.helpers) <= max_helpers, "Invalid Helper Data" for helper_num in range(0, len(self.helpers)): - assert type(self.helpers[helper_num]) == dict, "Invalid Helper Data" - text_envelope_args.append(Text(tab_label=f"helper{helper_num}_name", value=self.helpers[helper_num]['name'])) - number_envelope_args.append(Number(tab_label=f"helper{helper_num}_phone_number", value=self.helpers[helper_num]['phoneNumber'])) + text_envelope_args.append(Text(tab_label=f"helper{helper_num}_name", value=self.helpers[helper_num].name)) + number_envelope_args.append(Number(tab_label=f"helper{helper_num}_phone_number", value=self.helpers[helper_num].phone_number)) # Generate date for contract current_date = datetime.datetime.now() diff --git a/utilities/__init__.py b/utilities/__init__.py index 26e4633..658da5a 100644 --- a/utilities/__init__.py +++ b/utilities/__init__.py @@ -1,4 +1,4 @@ -from .flask_responses import FlaskResponses import types from .rbac import * -from .exceptions import * \ No newline at end of file +from .exceptions import * +from .auth import * \ No newline at end of file diff --git a/utilities/auth/__init__.py b/utilities/auth/__init__.py new file mode 100644 index 0000000..af2131e --- /dev/null +++ b/utilities/auth/__init__.py @@ -0,0 +1 @@ +from .cognito import get_current_user \ No newline at end of file diff --git a/utilities/auth/cognito.py b/utilities/auth/cognito.py new file mode 100644 index 0000000..60a1fca --- /dev/null +++ b/utilities/auth/cognito.py @@ -0,0 +1,14 @@ +from fastapi_cloudauth.cognito import CognitoCurrentUser, CognitoClaims +from config.env import COGNITO_APP_CLIENT_ID, COGNITO_REGION, COGNITO_USERPOOL_ID +from pydantic import Field + + +class CustomAuthClaims(CognitoClaims): # type: ignore[no-any-unimported] + sub: str = Field(alias="sub") + + +get_current_user = CognitoCurrentUser( + region=COGNITO_REGION, + userPoolId=COGNITO_USERPOOL_ID, + client_id=COGNITO_APP_CLIENT_ID +).claim(CustomAuthClaims) diff --git a/utilities/flask_responses.py b/utilities/flask_responses.py deleted file mode 100644 index d0448a7..0000000 --- a/utilities/flask_responses.py +++ /dev/null @@ -1,25 +0,0 @@ -from http import HTTPStatus -from utilities.types import FlaskResponseType, JSONType - - -class FlaskResponses(): - - @classmethod - def not_implemented_yet(cls) -> FlaskResponseType: - return {'error': "not implemented yet"}, HTTPStatus.NOT_IMPLEMENTED - - @classmethod - def success(cls, data: JSONType) -> FlaskResponseType: - return data, HTTPStatus.OK - - @classmethod - def created_resource(cls, data: JSONType) -> FlaskResponseType: - return data, HTTPStatus.CREATED - - @classmethod - def conflict(cls, data: JSONType) -> FlaskResponseType: - return data, HTTPStatus.CONFLICT - - @classmethod - def bad_request(cls, msg: str) -> FlaskResponseType: - return {'error': msg}, HTTPStatus.BAD_REQUEST diff --git a/utilities/rbac/roles.py b/utilities/rbac/roles.py index cc20253..c976379 100644 --- a/utilities/rbac/roles.py +++ b/utilities/rbac/roles.py @@ -2,7 +2,7 @@ class Roles: - CAN_MODIFY_CUSTOMER = "CanEditCustomerRoles" + CAN_MODIFY_CUSTOMER = "CanEditCustomer" CAN_MODIFY_DEVELOPER = "CanEditDeveloper" CAN_MODIFY_STAFF = "CanModifyStaff" diff --git a/utilities/types/__init__.py b/utilities/types/__init__.py index 27e7c13..4af79d2 100644 --- a/utilities/types/__init__.py +++ b/utilities/types/__init__.py @@ -1,3 +1,4 @@ from .contract_types import * -from .flask_response_types import * -from .database_types import * \ No newline at end of file +from .response_types import * +from .database_types import * +from .fields import phone_number \ No newline at end of file diff --git a/utilities/types/contract_types.py b/utilities/types/contract_types.py index 984c905..ef3f053 100644 --- a/utilities/types/contract_types.py +++ b/utilities/types/contract_types.py @@ -1,4 +1,10 @@ -from typing import List, Dict, Union +from typing import Dict, Union +from pydantic import BaseModel, Field +from .fields import phone_number Helper = Dict[str, Union[str, int]] -HelperData = List[Helper] + + +class HelperModel(BaseModel): + name: str = Field(alias="name", examples=["Bob"]) + phone_number: int = phone_number("phoneNumber") diff --git a/utilities/types/fields.py b/utilities/types/fields.py new file mode 100644 index 0000000..7ead678 --- /dev/null +++ b/utilities/types/fields.py @@ -0,0 +1,18 @@ +from pydantic import Field +from config import Config +from enum import Enum + +config = Config() +phone_number_max = config.get_contract_limit("phone_number_max") +phone_number_min = config.get_contract_limit("phone_number_min") + + +def phone_number(alias: str) -> int: + # This is technically a pydantic.fields.FieldInfo, but we will trick mypy so it's callers can be defined correctly + result: int = Field(alias=alias, ge=phone_number_max, le=phone_number_min, examples=['11234567890']) + return result + + +class VendorTypeEnum(Enum): + artist = 'artist' + dealer = 'dealer' diff --git a/utilities/types/flask_response_types.py b/utilities/types/response_types.py similarity index 54% rename from utilities/types/flask_response_types.py rename to utilities/types/response_types.py index fc301f7..2bcaca1 100644 --- a/utilities/types/flask_response_types.py +++ b/utilities/types/response_types.py @@ -1,9 +1,7 @@ -from typing import Dict, Any, Union, List, Tuple -from http import HTTPStatus +from typing import Dict, Any, Union, List # https://github.com/python/typing/issues/182 JSONType = Union[str, int, float, bool, None, Dict[str, Any], List[Any]] JSONDict = Dict[str, Any] -FlaskResponseType = Tuple[JSONType, HTTPStatus]