├── .env ├── .gitignore ├── Dockerfile ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── app ├── __init__.py ├── api │ ├── __init__.py │ ├── api.py │ ├── deps.py │ └── routers │ │ ├── __init__.py │ │ ├── dogs.py │ │ ├── security.py │ │ ├── tasks.py │ │ ├── upload_file.py │ │ └── users.py ├── config.py ├── core │ ├── __init__.py │ └── security │ │ ├── __init__.py │ │ ├── pwd.py │ │ ├── security.py │ │ └── token.py ├── crud │ ├── __init__.py │ ├── base.py │ ├── dog_crud.py │ ├── superuser_crud.py │ ├── user_crud.py │ └── web_crud.py ├── db │ ├── __init__.py │ ├── base.py │ ├── data │ │ ├── __init__.py │ │ └── superusers_fake_db.py │ ├── db_manager.py │ ├── session.py │ └── utils │ │ ├── __init__.py │ │ ├── parse_dicts.py │ │ └── populate_tables.py ├── main.py ├── models │ ├── __init__.py │ ├── base_class.py │ ├── dog.py │ └── user.py ├── schemas │ ├── __init__.py │ ├── base_config.py │ ├── dog.py │ ├── security.py │ ├── tasks.py │ ├── upload.py │ └── user.py ├── utils │ ├── __init__.py │ ├── http_request.py │ └── paths.py └── worker │ ├── __init__.py │ ├── celery_app.py │ └── tasks.py ├── docker-compose.yml ├── img ├── 2-guane-logo.png ├── arch.png ├── docs.png └── guane-logo.png ├── mock_data ├── __init__.py └── db_test_data.py ├── pyproject.toml ├── requirements.txt ├── scripts ├── __init__.py ├── app │ ├── rebuild_venv.sh │ └── run_tests.py ├── db │ ├── drop_all_db_tables.py │ └── drop_all_test_db_tables.py ├── docker │ ├── force-prune-build-reqs-build-docker.sh │ ├── force-prune-build.sh │ ├── prune-build.sh │ └── rebuild-venv-force-prune-build.sh ├── server │ ├── reset_rabbitmq.py │ └── run_server.py └── utils │ ├── __init__.py │ ├── _celery.py │ ├── _manage_services.py │ ├── _postgres.py │ ├── _rabbitmq.py │ └── _redis.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── app ├── api │ └── routers │ │ ├── __init__.py │ │ ├── test_dogs.py │ │ ├── test_security.py │ │ ├── test_tasks.py │ │ ├── test_upload_file.py │ │ └── test_users.py ├── crud │ ├── __init__.py │ ├── test_dog_crud.py │ └── test_user_crud.py ├── test_app.py ├── test_conf.py ├── test_main.py ├── utils │ ├── __init__.py │ └── test_http_requests.py └── worker │ ├── __init__.py │ └── test_celery_tasks.py ├── conftest.py ├── mock ├── __init__.py └── db_session.py └── utils ├── __init__.py ├── handle_db_test.py ├── parse_dict.py ├── security.py └── uri.py /.env: -------------------------------------------------------------------------------- 1 | ###### IMPORTANT NOTE 2 | # For security reasons this file is meant NOT to be commited to the repository, 3 | # but for the purpose of completness of the application it needs to. In a 4 | # normal developement purpose, this file would be shared by other means with 5 | # the development team members. 6 | 7 | 8 | 9 | ### Uvicorn Server 10 | ############################################################################### 11 | 12 | # Please do not modify $BACKEND_HOST 13 | BACKEND_HOST=0.0.0.0 14 | BACKEND_PORT=8080 15 | SERVER_WORKERS=1 16 | 17 | 18 | 19 | ### FastAPI application 20 | ############################################################################### 21 | 22 | PROJECT_TITLE=FastAPI-PostgreSQL-Celery project 23 | 24 | # Routers prefixes 25 | API_PREFIX=/api 26 | DOGS_API_PREFIX=/dogs 27 | USERS_API_PREFIX=/users 28 | UPLOAD_API_PREFIX=/upload 29 | SECURITY_PREFIX=/security 30 | CELERY_TASKS_PREFIX=/tasks 31 | 32 | ### Security config. Each variable should be a comma separated string. ### 33 | 34 | ALLOWED_HOSTS=* 35 | ALLOWED_ORIGINS=* 36 | ALLOWED_METHODS=* 37 | ALLOWED_HEADERS=* 38 | 39 | # JWT Config 40 | 41 | SECRET_KEY=cdd0ad817f0cdbc1f28c532875d2802953ab76e9491da221a848dbc15a2c9fb8 42 | ALGORITHM=HS256 43 | ACCESS_TOKEN_EXPIRE_MINUTES=30 44 | 45 | # $TOKEN_URI should be hardcoded to meet this format (same reason as $POSGRES_URI) 46 | # $TOKEN_URI=${API_PREFIX}${SECURITY_PREFIX}${TOKEN_RELATIVE_ROUTE} 47 | # e.g. in this case 48 | 49 | TOKEN_RELATIVE_ROUTE=/token 50 | TOKEN_URI=/api/security/token 51 | 52 | # This fields must match the data stored in ``superusers_db`` variable 53 | # from ``app.db.data.superusers_fake_db`` module 54 | 55 | FIRST_SUPERUSER=guane 56 | FIRST_SUPERUSER_PASSWORD=ilovethori 57 | 58 | # System path of the png file to upload to $UPLOAD_FILE_URI 59 | # This path should be relative to the ``app.api.routers.upload_file`` module 60 | 61 | UPLOAD_FILE_PATH=../../../img/guane-logo.png 62 | 63 | 64 | 65 | ### RabbitMQ 66 | ############################################################################### 67 | 68 | # Please do not modify $RABBITMQ_PORT 69 | RABBITMQ_PORT=5672 70 | RABBITMQ_PORT_2=15672 71 | RABBITMQ_DEFAULT_USER=guane 72 | RABBITMQ_DEFAULT_PASS=ilovefuelai 73 | RABBITMQ_DEFAULT_VHOST=fuelai 74 | 75 | # Please hardcode the fields defined before using the format 76 | # amqp://{RABBITMQ_DEFAULT_USER}:${RABBITMQ_DEFAULT_PASS}@0.0.0.0:${RABBITMQ_PORT} 77 | # e.g. 78 | RABBITMQ_URI=amqp://guane:ilovefuelai@rabbitmq:5672/fuelai 79 | 80 | 81 | 82 | ### Reddis 83 | ############################################################################### 84 | 85 | CELERY_BAKCEND_URI=redis://redis 86 | 87 | # These are just for the local (as opposed to docker) build 88 | REDIS_PORT=6379 89 | 90 | 91 | 92 | 93 | ### Database Config 94 | ############################################################################### 95 | 96 | # This values are used by docker 97 | POSTGRES_USER=guane 98 | POSTGRES_PASSWORD=thori 99 | POSTGRES_HOST=postgres 100 | POSTGRES_PORT=5432 101 | POSTGRES_DB=guane-default 102 | 103 | # Due to some bug in load_dotenv() when run form the docker container, we need to 104 | # hardcode again all the POSTGRES_URI. PLEASE set ${POSTGRES_URI} with THE SAME 105 | # VALUES defined in ${POSTGRES_USER}, ${POSTGRES_PASSWORD}, ${POSTGRES_HOST}, 106 | # ${POSTGRES_PORT}, ${POSTGRES_DB} and using the following format: 107 | # POSTGRES_URI=postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} 108 | # e.g. for the values defined just before: 109 | 110 | POSTGRES_URI=postgresql+psycopg2://guane:thori@postgres:5432/guane-default 111 | 112 | # Set the URI for your local build (as opposed to docker build) 113 | # In this case it is a userless, paswordless database named `guane` 114 | # listening at localhost:5432 115 | 116 | POSTGRES_LOCAL_URI=postgresql+psycopg2://localhost:5432/guane 117 | 118 | 119 | 120 | ### Testing Database Config 121 | 122 | # This variable is used in the docker image to test the app. Unfortunately, it 123 | # needs to be the same as the usual database, because the postgres docker image 124 | # only creates one database in its initialization script 125 | 126 | POSTGRES_TESTS_URI=postgresql+psycopg2://guane:thori@postgres:5432/guane-default 127 | 128 | # Set the db URI for your local tests (as opposed to docker tests) 129 | # In this case it is a userless, paswordless database named `guane-tests` 130 | # listening at localhost:5432 131 | 132 | POSTGRES_LOCAL_TESTS_URI=postgresql+psycopg2://localhost:5432/guane-tests 133 | 134 | 135 | 136 | ### External APIs 137 | ############################################################################### 138 | 139 | # Requests timeout 140 | REQUESTS_TIMEOUT=20 141 | 142 | # Dog API 143 | DOG_API_URI=https://dog.ceo/api/breeds/image/random 144 | 145 | # Guane upload file URI 146 | UPLOAD_FILE_URI=https://gttb.guane.dev/api/files 147 | 148 | # Guane test celery URI 149 | GUANE_WORKER_URI=https://gttb.guane.dev/api/workers 150 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .venv 106 | env/ 107 | venv/ 108 | ENV/ 109 | env.bak/ 110 | venv.bak/ 111 | 112 | # Spyder project settings 113 | .spyderproject 114 | .spyproject 115 | 116 | # Rope project settings 117 | .ropeproject 118 | 119 | # mkdocs documentation 120 | /site 121 | 122 | # mypy 123 | .mypy_cache/ 124 | .dmypy.json 125 | dmypy.json 126 | 127 | # Pyre type checker 128 | .pyre/ 129 | 130 | # Personal 131 | ############################################################################## 132 | 133 | # Docs 134 | internal_docs/ 135 | 136 | # Postgres database 137 | postgres-data/* 138 | !postgres-data/empty-file.txt 139 | 140 | # RabbitMQ 141 | **dump** 142 | 143 | 144 | # Visual Studio Code 145 | ############################################################################## 146 | .vscode/* 147 | # !.vscode/settings.json 148 | # !.vscode/tasks.json 149 | # !.vscode/launch.json 150 | # !.vscode/extensions.json 151 | *.code-workspace 152 | 153 | # Local History for Visual Studio Code 154 | .history/ 155 | 156 | 157 | # MacOS 158 | ############################################################################## 159 | # General 160 | .DS_Store 161 | .AppleDouble 162 | .LSOverride 163 | 164 | # Icon must end with two \r 165 | Icon 166 | 167 | # Thumbnails 168 | ._* 169 | 170 | # Files that might appear in the root of a volume 171 | .DocumentRevisions-V100 172 | .fseventsd 173 | .Spotlight-V100 174 | .TemporaryItems 175 | .Trashes 176 | .VolumeIcon.icns 177 | .com.apple.timemachine.donotpresent 178 | 179 | # Directories potentially created on remote AFP share 180 | .AppleDB 181 | .AppleDesktop 182 | Network Trash Folder 183 | Temporary Items 184 | .apdisk -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim-buster 2 | EXPOSE ${BACKEND_PORT} 3 | 4 | ENV PYTHONUNBUFFERED 1 5 | ENV APP_HOME=/app 6 | 7 | WORKDIR ${APP_HOME} 8 | 9 | # Copy all needed files 10 | # Needed directories 11 | COPY app app 12 | COPY img img 13 | COPY mock_data mock_data 14 | COPY scripts scripts 15 | COPY tests tests 16 | COPY .env ./ 17 | COPY setup.cfg ./ 18 | COPY setup.py ./ 19 | COPY pyproject.toml ./ 20 | COPY requirements.txt ./ 21 | 22 | RUN pip install --no-cache-dir -U pip 23 | RUN pip install -v --no-cache-dir -U -r requirements.txt --src /app 24 | 25 | # Options for entrypoint: 26 | # --populate-tables (load data from ``mock_data.db_test_data module`` into tables) 27 | # --drop-tables (drop tables after server is shut down) 28 | 29 | # NOTE 1.0: just add-delete the options in ENTYPOINT command as you desire 30 | # NOTE 2.0: if you change the options you first need to prune your containers, 31 | # then rebuild using ``docker system prune -a`` and then ``docker compose up --build`` 32 | # or, if you prefer you can run ``sh scripts/docker/prune-build.sh`` 33 | 34 | ENTRYPOINT ["python", "./scripts/server/run_server.py", "--populate-tables", "--drop-tables"] 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Juan Esteban Aristizabal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | app = {editable = true, path = "."} 8 | fastapi = "*" 9 | sqlalchemy = "*" 10 | uvicorn = "*" 11 | python-dotenv = "*" 12 | requests = "*" 13 | psycopg2-binary = "*" 14 | email-validator = "*" 15 | typer = "*" 16 | python-jose = {extras = ["cryptography"], version = "*"} 17 | passlib = {extras = ["bcrypt"], version = "*"} 18 | python-multipart = "*" 19 | celery = "*" 20 | redis = "*" 21 | 22 | [dev-packages] 23 | pytest = "*" 24 | pytest-cov = "*" 25 | flake8 = "*" 26 | 27 | [scripts] 28 | # Init server with local database using $POSTGRES_LOCAL_URI defined in ~/.env 29 | server = "python ./scripts/server/run_server.py --no-docker --populate-tables --drop-tables --auto-reload-server" 30 | server-docker = "python ./scripts/server/run_server.py" 31 | server-docker-with-data = "python ./scripts/server/run_server.py --populate-tables --drop-tables" 32 | # Tests 33 | tests = "python scripts/app/run_tests.py --no-docker" 34 | tests-log = "python scripts/app/run_tests.py --no-docker --print-all" 35 | tests-html = "python scripts/app/run_tests.py --no-docker --cov-html" 36 | # Linting - style 37 | linter = "flake8 app/ tests/ scripts/ mock_data/" 38 | # Database 39 | # WARNING: running the following scripts may cause inevitable loss of information 40 | drop-all-tables = "python scripts/drop_all_db_tables.py" 41 | drop-all-test-tables = "python scripts/drop_all_test_db_tables.py" 42 | 43 | [requires] 44 | python_version = "3.9" 45 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "e44e3b58b6ba7e988ecd57a51ec591e9e438c75c1885d0619cef91be9906af31" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.9" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "amqp": { 20 | "hashes": [ 21 | "sha256:03e16e94f2b34c31f8bf1206d8ddd3ccaa4c315f7f6a1879b7b1210d229568c2", 22 | "sha256:493a2ac6788ce270a2f6a765b017299f60c1998f5a8617908ee9be082f7300fb" 23 | ], 24 | "markers": "python_version >= '3.6'", 25 | "version": "==5.0.6" 26 | }, 27 | "app": { 28 | "editable": true, 29 | "path": "." 30 | }, 31 | "asgiref": { 32 | "hashes": [ 33 | "sha256:92906c611ce6c967347bbfea733f13d6313901d54dcca88195eaeb52b2a8e8ee", 34 | "sha256:d1216dfbdfb63826470995d31caed36225dcaf34f182e0fa257a4dd9e86f1b78" 35 | ], 36 | "markers": "python_version >= '3.6'", 37 | "version": "==3.3.4" 38 | }, 39 | "bcrypt": { 40 | "hashes": [ 41 | "sha256:5b93c1726e50a93a033c36e5ca7fdcd29a5c7395af50a6892f5d9e7c6cfbfb29", 42 | "sha256:63d4e3ff96188e5898779b6057878fecf3f11cfe6ec3b313ea09955d587ec7a7", 43 | "sha256:81fec756feff5b6818ea7ab031205e1d323d8943d237303baca2c5f9c7846f34", 44 | "sha256:a67fb841b35c28a59cebed05fbd3e80eea26e6d75851f0574a9273c80f3e9b55", 45 | "sha256:c95d4cbebffafcdd28bd28bb4e25b31c50f6da605c81ffd9ad8a3d1b2ab7b1b6", 46 | "sha256:cd1ea2ff3038509ea95f687256c46b79f5fc382ad0aa3664d200047546d511d1", 47 | "sha256:cdcdcb3972027f83fe24a48b1e90ea4b584d35f1cc279d76de6fc4b13376239d" 48 | ], 49 | "version": "==3.2.0" 50 | }, 51 | "billiard": { 52 | "hashes": [ 53 | "sha256:299de5a8da28a783d51b197d496bef4f1595dd023a93a4f59dde1886ae905547", 54 | "sha256:87103ea78fa6ab4d5c751c4909bcff74617d985de7fa8b672cf8618afd5a875b" 55 | ], 56 | "version": "==3.6.4.0" 57 | }, 58 | "celery": { 59 | "hashes": [ 60 | "sha256:1329de1edeaf734ef859e630cb42df2c116d53e59d2f46433b13aed196e85620", 61 | "sha256:65f061c04578cf189cd7352c192e1a79fdeb370b916bff792bcc769560e81184" 62 | ], 63 | "index": "pypi", 64 | "version": "==5.1.0" 65 | }, 66 | "certifi": { 67 | "hashes": [ 68 | "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", 69 | "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8" 70 | ], 71 | "version": "==2021.5.30" 72 | }, 73 | "cffi": { 74 | "hashes": [ 75 | "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813", 76 | "sha256:04c468b622ed31d408fea2346bec5bbffba2cc44226302a0de1ade9f5ea3d373", 77 | "sha256:06d7cd1abac2ffd92e65c0609661866709b4b2d82dd15f611e602b9b188b0b69", 78 | "sha256:06db6321b7a68b2bd6df96d08a5adadc1fa0e8f419226e25b2a5fbf6ccc7350f", 79 | "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06", 80 | "sha256:0f861a89e0043afec2a51fd177a567005847973be86f709bbb044d7f42fc4e05", 81 | "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea", 82 | "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee", 83 | "sha256:1bf1ac1984eaa7675ca8d5745a8cb87ef7abecb5592178406e55858d411eadc0", 84 | "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396", 85 | "sha256:24a570cd11895b60829e941f2613a4f79df1a27344cbbb82164ef2e0116f09c7", 86 | "sha256:24ec4ff2c5c0c8f9c6b87d5bb53555bf267e1e6f70e52e5a9740d32861d36b6f", 87 | "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73", 88 | "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315", 89 | "sha256:293e7ea41280cb28c6fcaaa0b1aa1f533b8ce060b9e701d78511e1e6c4a1de76", 90 | "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1", 91 | "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49", 92 | "sha256:3c3f39fa737542161d8b0d680df2ec249334cd70a8f420f71c9304bd83c3cbed", 93 | "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892", 94 | "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482", 95 | "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058", 96 | "sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5", 97 | "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53", 98 | "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045", 99 | "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3", 100 | "sha256:681d07b0d1e3c462dd15585ef5e33cb021321588bebd910124ef4f4fb71aef55", 101 | "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5", 102 | "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e", 103 | "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c", 104 | "sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369", 105 | "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827", 106 | "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053", 107 | "sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa", 108 | "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4", 109 | "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322", 110 | "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132", 111 | "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62", 112 | "sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa", 113 | "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0", 114 | "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396", 115 | "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e", 116 | "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991", 117 | "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6", 118 | "sha256:cc5a8e069b9ebfa22e26d0e6b97d6f9781302fe7f4f2b8776c3e1daea35f1adc", 119 | "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1", 120 | "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406", 121 | "sha256:df5052c5d867c1ea0b311fb7c3cd28b19df469c056f7fdcfe88c7473aa63e333", 122 | "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d", 123 | "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c" 124 | ], 125 | "version": "==1.14.5" 126 | }, 127 | "chardet": { 128 | "hashes": [ 129 | "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", 130 | "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" 131 | ], 132 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 133 | "version": "==4.0.0" 134 | }, 135 | "click": { 136 | "hashes": [ 137 | "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", 138 | "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" 139 | ], 140 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 141 | "version": "==7.1.2" 142 | }, 143 | "click-didyoumean": { 144 | "hashes": [ 145 | "sha256:112229485c9704ff51362fe34b2d4f0b12fc71cc20f6d2b3afabed4b8bfa6aeb" 146 | ], 147 | "version": "==0.0.3" 148 | }, 149 | "click-plugins": { 150 | "hashes": [ 151 | "sha256:46ab999744a9d831159c3411bb0c79346d94a444df9a3a3742e9ed63645f264b", 152 | "sha256:5d262006d3222f5057fd81e1623d4443e41dcda5dc815c06b442aa3c02889fc8" 153 | ], 154 | "version": "==1.1.1" 155 | }, 156 | "click-repl": { 157 | "hashes": [ 158 | "sha256:94b3fbbc9406a236f176e0506524b2937e4b23b6f4c0c0b2a0a83f8a64e9194b", 159 | "sha256:cd12f68d745bf6151210790540b4cb064c7b13e571bc64b6957d98d120dacfd8" 160 | ], 161 | "version": "==0.2.0" 162 | }, 163 | "cryptography": { 164 | "hashes": [ 165 | "sha256:0f1212a66329c80d68aeeb39b8a16d54ef57071bf22ff4e521657b27372e327d", 166 | "sha256:1e056c28420c072c5e3cb36e2b23ee55e260cb04eee08f702e0edfec3fb51959", 167 | "sha256:240f5c21aef0b73f40bb9f78d2caff73186700bf1bc6b94285699aff98cc16c6", 168 | "sha256:26965837447f9c82f1855e0bc8bc4fb910240b6e0d16a664bb722df3b5b06873", 169 | "sha256:37340614f8a5d2fb9aeea67fd159bfe4f5f4ed535b1090ce8ec428b2f15a11f2", 170 | "sha256:3d10de8116d25649631977cb37da6cbdd2d6fa0e0281d014a5b7d337255ca713", 171 | "sha256:3d8427734c781ea5f1b41d6589c293089704d4759e34597dce91014ac125aad1", 172 | "sha256:7ec5d3b029f5fa2b179325908b9cd93db28ab7b85bb6c1db56b10e0b54235177", 173 | "sha256:8e56e16617872b0957d1c9742a3f94b43533447fd78321514abbe7db216aa250", 174 | "sha256:de4e5f7f68220d92b7637fc99847475b59154b7a1b3868fb7385337af54ac9ca", 175 | "sha256:eb8cc2afe8b05acbd84a43905832ec78e7b3873fb124ca190f574dca7389a87d", 176 | "sha256:ee77aa129f481be46f8d92a1a7db57269a2f23052d5f2433b4621bb457081cc9" 177 | ], 178 | "version": "==3.4.7" 179 | }, 180 | "dnspython": { 181 | "hashes": [ 182 | "sha256:95d12f6ef0317118d2a1a6fc49aac65ffec7eb8087474158f42f26a639135216", 183 | "sha256:e4a87f0b573201a0f3727fa18a516b055fd1107e0e5477cded4a2de497df1dd4" 184 | ], 185 | "markers": "python_version >= '3.6'", 186 | "version": "==2.1.0" 187 | }, 188 | "ecdsa": { 189 | "hashes": [ 190 | "sha256:5cf31d5b33743abe0dfc28999036c849a69d548f994b535e527ee3cb7f3ef676", 191 | "sha256:b9f500bb439e4153d0330610f5d26baaf18d17b8ced1bc54410d189385ea68aa" 192 | ], 193 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 194 | "version": "==0.17.0" 195 | }, 196 | "email-validator": { 197 | "hashes": [ 198 | "sha256:094b1d1c60d790649989d38d34f69e1ef07792366277a2cf88684d03495d018f", 199 | "sha256:1a13bd6050d1db4475f13e444e169b6fe872434922d38968c67cea9568cce2f0" 200 | ], 201 | "index": "pypi", 202 | "version": "==1.1.2" 203 | }, 204 | "fastapi": { 205 | "hashes": [ 206 | "sha256:39569a18914075b2f1aaa03bcb9dc96a38e0e5dabaf3972e088c9077dfffa379", 207 | "sha256:8359e55d8412a5571c0736013d90af235d6949ec4ce978e9b63500c8f4b6f714" 208 | ], 209 | "index": "pypi", 210 | "version": "==0.65.2" 211 | }, 212 | "greenlet": { 213 | "hashes": [ 214 | "sha256:03f28a5ea20201e70ab70518d151116ce939b412961c33827519ce620957d44c", 215 | "sha256:06d7ac89e6094a0a8f8dc46aa61898e9e1aec79b0f8b47b2400dd51a44dbc832", 216 | "sha256:06ecb43b04480e6bafc45cb1b4b67c785e183ce12c079473359e04a709333b08", 217 | "sha256:096cb0217d1505826ba3d723e8981096f2622cde1eb91af9ed89a17c10aa1f3e", 218 | "sha256:0c557c809eeee215b87e8a7cbfb2d783fb5598a78342c29ade561440abae7d22", 219 | "sha256:0de64d419b1cb1bfd4ea544bedea4b535ef3ae1e150b0f2609da14bbf48a4a5f", 220 | "sha256:14927b15c953f8f2d2a8dffa224aa78d7759ef95284d4c39e1745cf36e8cdd2c", 221 | "sha256:16183fa53bc1a037c38d75fdc59d6208181fa28024a12a7f64bb0884434c91ea", 222 | "sha256:206295d270f702bc27dbdbd7651e8ebe42d319139e0d90217b2074309a200da8", 223 | "sha256:22002259e5b7828b05600a762579fa2f8b33373ad95a0ee57b4d6109d0e589ad", 224 | "sha256:2325123ff3a8ecc10ca76f062445efef13b6cf5a23389e2df3c02a4a527b89bc", 225 | "sha256:258f9612aba0d06785143ee1cbf2d7361801c95489c0bd10c69d163ec5254a16", 226 | "sha256:3096286a6072553b5dbd5efbefc22297e9d06a05ac14ba017233fedaed7584a8", 227 | "sha256:3d13da093d44dee7535b91049e44dd2b5540c2a0e15df168404d3dd2626e0ec5", 228 | "sha256:408071b64e52192869129a205e5b463abda36eff0cebb19d6e63369440e4dc99", 229 | "sha256:598bcfd841e0b1d88e32e6a5ea48348a2c726461b05ff057c1b8692be9443c6e", 230 | "sha256:5d928e2e3c3906e0a29b43dc26d9b3d6e36921eee276786c4e7ad9ff5665c78a", 231 | "sha256:5f75e7f237428755d00e7460239a2482fa7e3970db56c8935bd60da3f0733e56", 232 | "sha256:60848099b76467ef09b62b0f4512e7e6f0a2c977357a036de602b653667f5f4c", 233 | "sha256:6b1d08f2e7f2048d77343279c4d4faa7aef168b3e36039cba1917fffb781a8ed", 234 | "sha256:70bd1bb271e9429e2793902dfd194b653221904a07cbf207c3139e2672d17959", 235 | "sha256:76ed710b4e953fc31c663b079d317c18f40235ba2e3d55f70ff80794f7b57922", 236 | "sha256:7920e3eccd26b7f4c661b746002f5ec5f0928076bd738d38d894bb359ce51927", 237 | "sha256:7db68f15486d412b8e2cfcd584bf3b3a000911d25779d081cbbae76d71bd1a7e", 238 | "sha256:8833e27949ea32d27f7e96930fa29404dd4f2feb13cce483daf52e8842ec246a", 239 | "sha256:944fbdd540712d5377a8795c840a97ff71e7f3221d3fddc98769a15a87b36131", 240 | "sha256:9a6b035aa2c5fcf3dbbf0e3a8a5bc75286fc2d4e6f9cfa738788b433ec894919", 241 | "sha256:9bdcff4b9051fb1aa4bba4fceff6a5f770c6be436408efd99b76fc827f2a9319", 242 | "sha256:a9017ff5fc2522e45562882ff481128631bf35da444775bc2776ac5c61d8bcae", 243 | "sha256:aa4230234d02e6f32f189fd40b59d5a968fe77e80f59c9c933384fe8ba535535", 244 | "sha256:ad80bb338cf9f8129c049837a42a43451fc7c8b57ad56f8e6d32e7697b115505", 245 | "sha256:adb94a28225005890d4cf73648b5131e885c7b4b17bc762779f061844aabcc11", 246 | "sha256:b3090631fecdf7e983d183d0fad7ea72cfb12fa9212461a9b708ff7907ffff47", 247 | "sha256:b33b51ab057f8a20b497ffafdb1e79256db0c03ef4f5e3d52e7497200e11f821", 248 | "sha256:b97c9a144bbeec7039cca44df117efcbeed7209543f5695201cacf05ba3b5857", 249 | "sha256:be13a18cec649ebaab835dff269e914679ef329204704869f2f167b2c163a9da", 250 | "sha256:be9768e56f92d1d7cd94185bab5856f3c5589a50d221c166cc2ad5eb134bd1dc", 251 | "sha256:c1580087ab493c6b43e66f2bdd165d9e3c1e86ef83f6c2c44a29f2869d2c5bd5", 252 | "sha256:c35872b2916ab5a240d52a94314c963476c989814ba9b519bc842e5b61b464bb", 253 | "sha256:c70c7dd733a4c56838d1f1781e769081a25fade879510c5b5f0df76956abfa05", 254 | "sha256:c767458511a59f6f597bfb0032a1c82a52c29ae228c2c0a6865cfeaeaac4c5f5", 255 | "sha256:c87df8ae3f01ffb4483c796fe1b15232ce2b219f0b18126948616224d3f658ee", 256 | "sha256:ca1c4a569232c063615f9e70ff9a1e2fee8c66a6fb5caf0f5e8b21a396deec3e", 257 | "sha256:cc407b68e0a874e7ece60f6639df46309376882152345508be94da608cc0b831", 258 | "sha256:da862b8f7de577bc421323714f63276acb2f759ab8c5e33335509f0b89e06b8f", 259 | "sha256:dfe7eac0d253915116ed0cd160a15a88981a1d194c1ef151e862a5c7d2f853d3", 260 | "sha256:ed1377feed808c9c1139bdb6a61bcbf030c236dd288d6fca71ac26906ab03ba6", 261 | "sha256:f42ad188466d946f1b3afc0a9e1a266ac8926461ee0786c06baac6bd71f8a6f3", 262 | "sha256:f92731609d6625e1cc26ff5757db4d32b6b810d2a3363b0ff94ff573e5901f6f" 263 | ], 264 | "markers": "python_version >= '3'", 265 | "version": "==1.1.0" 266 | }, 267 | "h11": { 268 | "hashes": [ 269 | "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6", 270 | "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042" 271 | ], 272 | "markers": "python_version >= '3.6'", 273 | "version": "==0.12.0" 274 | }, 275 | "idna": { 276 | "hashes": [ 277 | "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", 278 | "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" 279 | ], 280 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 281 | "version": "==2.10" 282 | }, 283 | "kombu": { 284 | "hashes": [ 285 | "sha256:01481d99f4606f6939cdc9b637264ed353ee9e3e4f62cfb582324142c41a572d", 286 | "sha256:e2dedd8a86c9077c350555153825a31e456a0dc20c15d5751f00137ec9c75f0a" 287 | ], 288 | "markers": "python_version >= '3.6'", 289 | "version": "==5.1.0" 290 | }, 291 | "passlib": { 292 | "extras": [ 293 | "bcrypt" 294 | ], 295 | "hashes": [ 296 | "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", 297 | "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04" 298 | ], 299 | "index": "pypi", 300 | "version": "==1.7.4" 301 | }, 302 | "prompt-toolkit": { 303 | "hashes": [ 304 | "sha256:bf00f22079f5fadc949f42ae8ff7f05702826a97059ffcc6281036ad40ac6f04", 305 | "sha256:e1b4f11b9336a28fa11810bc623c357420f69dfdb6d2dac41ca2c21a55c033bc" 306 | ], 307 | "markers": "python_full_version >= '3.6.1'", 308 | "version": "==3.0.18" 309 | }, 310 | "psycopg2-binary": { 311 | "hashes": [ 312 | "sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c", 313 | "sha256:0e4dc3d5996760104746e6cfcdb519d9d2cd27c738296525d5867ea695774e67", 314 | "sha256:11b9c0ebce097180129e422379b824ae21c8f2a6596b159c7659e2e5a00e1aa0", 315 | "sha256:15978a1fbd225583dd8cdaf37e67ccc278b5abecb4caf6b2d6b8e2b948e953f6", 316 | "sha256:1fabed9ea2acc4efe4671b92c669a213db744d2af8a9fc5d69a8e9bc14b7a9db", 317 | "sha256:2dac98e85565d5688e8ab7bdea5446674a83a3945a8f416ad0110018d1501b94", 318 | "sha256:42ec1035841b389e8cc3692277a0bd81cdfe0b65d575a2c8862cec7a80e62e52", 319 | "sha256:6422f2ff0919fd720195f64ffd8f924c1395d30f9a495f31e2392c2efafb5056", 320 | "sha256:6a32f3a4cb2f6e1a0b15215f448e8ce2da192fd4ff35084d80d5e39da683e79b", 321 | "sha256:7312e931b90fe14f925729cde58022f5d034241918a5c4f9797cac62f6b3a9dd", 322 | "sha256:7d92a09b788cbb1aec325af5fcba9fed7203897bbd9269d5691bb1e3bce29550", 323 | "sha256:833709a5c66ca52f1d21d41865a637223b368c0ee76ea54ca5bad6f2526c7679", 324 | "sha256:89705f45ce07b2dfa806ee84439ec67c5d9a0ef20154e0e475e2b2ed392a5b83", 325 | "sha256:8cd0fb36c7412996859cb4606a35969dd01f4ea34d9812a141cd920c3b18be77", 326 | "sha256:950bc22bb56ee6ff142a2cb9ee980b571dd0912b0334aa3fe0fe3788d860bea2", 327 | "sha256:a0c50db33c32594305b0ef9abc0cb7db13de7621d2cadf8392a1d9b3c437ef77", 328 | "sha256:a0eb43a07386c3f1f1ebb4dc7aafb13f67188eab896e7397aa1ee95a9c884eb2", 329 | "sha256:aaa4213c862f0ef00022751161df35804127b78adf4a2755b9f991a507e425fd", 330 | "sha256:ac0c682111fbf404525dfc0f18a8b5f11be52657d4f96e9fcb75daf4f3984859", 331 | "sha256:ad20d2eb875aaa1ea6d0f2916949f5c08a19c74d05b16ce6ebf6d24f2c9f75d1", 332 | "sha256:b4afc542c0ac0db720cf516dd20c0846f71c248d2b3d21013aa0d4ef9c71ca25", 333 | "sha256:b8a3715b3c4e604bcc94c90a825cd7f5635417453b253499664f784fc4da0152", 334 | "sha256:ba28584e6bca48c59eecbf7efb1576ca214b47f05194646b081717fa628dfddf", 335 | "sha256:ba381aec3a5dc29634f20692349d73f2d21f17653bda1decf0b52b11d694541f", 336 | "sha256:bd1be66dde2b82f80afb9459fc618216753f67109b859a361cf7def5c7968729", 337 | "sha256:c2507d796fca339c8fb03216364cca68d87e037c1f774977c8fc377627d01c71", 338 | "sha256:cec7e622ebc545dbb4564e483dd20e4e404da17ae07e06f3e780b2dacd5cee66", 339 | "sha256:d14b140a4439d816e3b1229a4a525df917d6ea22a0771a2a78332273fd9528a4", 340 | "sha256:d1b4ab59e02d9008efe10ceabd0b31e79519da6fb67f7d8e8977118832d0f449", 341 | "sha256:d5227b229005a696cc67676e24c214740efd90b148de5733419ac9aaba3773da", 342 | "sha256:e1f57aa70d3f7cc6947fd88636a481638263ba04a742b4a37dd25c373e41491a", 343 | "sha256:e74a55f6bad0e7d3968399deb50f61f4db1926acf4a6d83beaaa7df986f48b1c", 344 | "sha256:e82aba2188b9ba309fd8e271702bd0d0fc9148ae3150532bbb474f4590039ffb", 345 | "sha256:ee69dad2c7155756ad114c02db06002f4cded41132cc51378e57aad79cc8e4f4", 346 | "sha256:f5ab93a2cb2d8338b1674be43b442a7f544a0971da062a5da774ed40587f18f5" 347 | ], 348 | "index": "pypi", 349 | "version": "==2.8.6" 350 | }, 351 | "pyasn1": { 352 | "hashes": [ 353 | "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", 354 | "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", 355 | "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", 356 | "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", 357 | "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", 358 | "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", 359 | "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", 360 | "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", 361 | "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", 362 | "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", 363 | "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", 364 | "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", 365 | "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" 366 | ], 367 | "version": "==0.4.8" 368 | }, 369 | "pycparser": { 370 | "hashes": [ 371 | "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", 372 | "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" 373 | ], 374 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 375 | "version": "==2.20" 376 | }, 377 | "pydantic": { 378 | "hashes": [ 379 | "sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd", 380 | "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739", 381 | "sha256:05ef5246a7ffd2ce12a619cbb29f3307b7c4509307b1b49f456657b43529dc6f", 382 | "sha256:10e5622224245941efc193ad1d159887872776df7a8fd592ed746aa25d071840", 383 | "sha256:18b5ea242dd3e62dbf89b2b0ec9ba6c7b5abaf6af85b95a97b00279f65845a23", 384 | "sha256:234a6c19f1c14e25e362cb05c68afb7f183eb931dd3cd4605eafff055ebbf287", 385 | "sha256:244ad78eeb388a43b0c927e74d3af78008e944074b7d0f4f696ddd5b2af43c62", 386 | "sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b", 387 | "sha256:41b542c0b3c42dc17da70554bc6f38cbc30d7066d2c2815a94499b5684582ecb", 388 | "sha256:4a03cbbe743e9c7247ceae6f0d8898f7a64bb65800a45cbdc52d65e370570820", 389 | "sha256:4be75bebf676a5f0f87937c6ddb061fa39cbea067240d98e298508c1bda6f3f3", 390 | "sha256:54cd5121383f4a461ff7644c7ca20c0419d58052db70d8791eacbbe31528916b", 391 | "sha256:589eb6cd6361e8ac341db97602eb7f354551482368a37f4fd086c0733548308e", 392 | "sha256:8621559dcf5afacf0069ed194278f35c255dc1a1385c28b32dd6c110fd6531b3", 393 | "sha256:8b223557f9510cf0bfd8b01316bf6dd281cf41826607eada99662f5e4963f316", 394 | "sha256:99a9fc39470010c45c161a1dc584997f1feb13f689ecf645f59bb4ba623e586b", 395 | "sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4", 396 | "sha256:a83db7205f60c6a86f2c44a61791d993dff4b73135df1973ecd9eed5ea0bda20", 397 | "sha256:ac8eed4ca3bd3aadc58a13c2aa93cd8a884bcf21cb019f8cfecaae3b6ce3746e", 398 | "sha256:e710876437bc07bd414ff453ac8ec63d219e7690128d925c6e82889d674bb505", 399 | "sha256:ea5cb40a3b23b3265f6325727ddfc45141b08ed665458be8c6285e7b85bd73a1", 400 | "sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833" 401 | ], 402 | "markers": "python_full_version >= '3.6.1'", 403 | "version": "==1.8.2" 404 | }, 405 | "python-dotenv": { 406 | "hashes": [ 407 | "sha256:00aa34e92d992e9f8383730816359647f358f4a3be1ba45e5a5cefd27ee91544", 408 | "sha256:b1ae5e9643d5ed987fc57cc2583021e38db531946518130777734f9589b3141f" 409 | ], 410 | "index": "pypi", 411 | "version": "==0.17.1" 412 | }, 413 | "python-jose": { 414 | "extras": [ 415 | "cryptography" 416 | ], 417 | "hashes": [ 418 | "sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a", 419 | "sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a" 420 | ], 421 | "index": "pypi", 422 | "version": "==3.3.0" 423 | }, 424 | "python-multipart": { 425 | "hashes": [ 426 | "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43" 427 | ], 428 | "index": "pypi", 429 | "version": "==0.0.5" 430 | }, 431 | "pytz": { 432 | "hashes": [ 433 | "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da", 434 | "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798" 435 | ], 436 | "version": "==2021.1" 437 | }, 438 | "redis": { 439 | "hashes": [ 440 | "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2", 441 | "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24" 442 | ], 443 | "index": "pypi", 444 | "version": "==3.5.3" 445 | }, 446 | "requests": { 447 | "hashes": [ 448 | "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", 449 | "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" 450 | ], 451 | "index": "pypi", 452 | "version": "==2.25.1" 453 | }, 454 | "rsa": { 455 | "hashes": [ 456 | "sha256:78f9a9bf4e7be0c5ded4583326e7461e3a3c5aae24073648b4bdfa797d78c9d2", 457 | "sha256:9d689e6ca1b3038bc82bf8d23e944b6b6037bc02301a574935b2dd946e0353b9" 458 | ], 459 | "markers": "python_version >= '3.5' and python_version < '4'", 460 | "version": "==4.7.2" 461 | }, 462 | "six": { 463 | "hashes": [ 464 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 465 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 466 | ], 467 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 468 | "version": "==1.16.0" 469 | }, 470 | "sqlalchemy": { 471 | "hashes": [ 472 | "sha256:0653d444d52f2b9a0cba1ea5cd0fc64e616ee3838ee86c1863781b2a8670fc0c", 473 | "sha256:146af9e67d0f821b28779d602372e65d019db01532d8f7101e91202d447c14ec", 474 | "sha256:2129d33b54da4d4771868a3639a07f461adc5887dbd9e0a80dbf560272245525", 475 | "sha256:284b6df04bc30e886998e0fdbd700ef9ffb83bcb484ffc54d4084959240dce91", 476 | "sha256:3690fc0fc671419debdae9b33df1434ac9253155fd76d0f66a01f7b459d56ee6", 477 | "sha256:3a6afb7a55374329601c8fcad277f0a47793386255764431c8f6a231a6947ee9", 478 | "sha256:45bbb935b305e381bcb542bf4d952232282ba76881e3458105e4733ba0976060", 479 | "sha256:495cce8174c670f1d885e2259d710b0120888db2169ea14fc32d1f72e7950642", 480 | "sha256:4cdc91bb3ee5b10e24ec59303131b791f3f82caa4dd8b36064d1918b0f4d0de4", 481 | "sha256:4f375c52fed5f2ecd06be18756f121b3167a1fdc4543d877961fba04b1713214", 482 | "sha256:56958dd833145f1aa75f8987dfe0cf6f149e93aa31967b7004d4eb9cb579fefc", 483 | "sha256:5b827d3d1d982b38d2bab551edf9893c4734b5db9b852b28d3bc809ea7e179f6", 484 | "sha256:5c62fff70348e3f8e4392540d31f3b8c251dc8eb830173692e5d61896d4309d6", 485 | "sha256:5d4b2c23d20acf631456e645227cef014e7f84a111118d530cfa1d6053fd05a9", 486 | "sha256:60cfe1fb59a34569816907cb25bb256c9490824679c46777377bcc01f6813a81", 487 | "sha256:664c6cc84a5d2bad2a4a3984d146b6201b850ba0a7125b2fcd29ca06cddac4b1", 488 | "sha256:70674f2ff315a74061da7af1225770578d23f4f6f74dd2e1964493abd8d804bc", 489 | "sha256:77549e5ae996de50ad9f69f863c91daf04842b14233e133335b900b152bffb07", 490 | "sha256:8924d552decf1a50d57dca4984ebd0778a55ca2cb1c0ef16df8c1fed405ff290", 491 | "sha256:93394d68f02ecbf8c0a4355b6452793000ce0ee7aef79d2c85b491da25a88af7", 492 | "sha256:9a62b06ad450386a2e671d0bcc5cd430690b77a5cd41c54ede4e4bf46d7a4978", 493 | "sha256:c824d14b52000597dfcced0a4e480fd8664b09fed606e746a2c67fe5fbe8dfd9", 494 | "sha256:cc474d0c40cef94d9b68980155d686d5ad43a9ca0834a8729052d3585f289d57", 495 | "sha256:d25210f5f1a6b7b6b357d8fa199fc1d5be828c67cc1af517600c02e5b2727e4c", 496 | "sha256:d76abceeb6f7c564fdbc304b1ce17ec59664ca7ed0fe6dbc6fc6a960c91370e3", 497 | "sha256:e2aa39fdf5bff1c325a8648ac1957a0320c66763a3fa5f0f4a02457b2afcf372", 498 | "sha256:eba098a4962e1ab0d446c814ae67e30da82c446b382cf718306cc90d4e2ad85f", 499 | "sha256:ee3428f6100ff2b07e7ecec6357d865a4d604c801760094883587ecdbf8a3533", 500 | "sha256:f3357948fa439eb5c7241a8856738605d7ab9d9f276ca5c5cc3220455a5f8e6c", 501 | "sha256:ffb18eb56546aa66640fef831e5d0fe1a8dfbf11cdf5b00803826a01dbbbf3b1" 502 | ], 503 | "index": "pypi", 504 | "version": "==1.4.18" 505 | }, 506 | "starlette": { 507 | "hashes": [ 508 | "sha256:3c8e48e52736b3161e34c9f0e8153b4f32ec5d8995a3ee1d59410d92f75162ed", 509 | "sha256:7d49f4a27f8742262ef1470608c59ddbc66baf37c148e938c7038e6bc7a998aa" 510 | ], 511 | "markers": "python_version >= '3.6'", 512 | "version": "==0.14.2" 513 | }, 514 | "typer": { 515 | "hashes": [ 516 | "sha256:5455d750122cff96745b0dec87368f56d023725a7ebc9d2e54dd23dc86816303", 517 | "sha256:ba58b920ce851b12a2d790143009fa00ac1d05b3ff3257061ff69dbdfc3d161b" 518 | ], 519 | "index": "pypi", 520 | "version": "==0.3.2" 521 | }, 522 | "typing-extensions": { 523 | "hashes": [ 524 | "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497", 525 | "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342", 526 | "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84" 527 | ], 528 | "version": "==3.10.0.0" 529 | }, 530 | "urllib3": { 531 | "hashes": [ 532 | "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c", 533 | "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098" 534 | ], 535 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", 536 | "version": "==1.26.5" 537 | }, 538 | "uvicorn": { 539 | "hashes": [ 540 | "sha256:2a76bb359171a504b3d1c853409af3adbfa5cef374a4a59e5881945a97a93eae", 541 | "sha256:45ad7dfaaa7d55cab4cd1e85e03f27e9d60bc067ddc59db52a2b0aeca8870292" 542 | ], 543 | "index": "pypi", 544 | "version": "==0.14.0" 545 | }, 546 | "vine": { 547 | "hashes": [ 548 | "sha256:4c9dceab6f76ed92105027c49c823800dd33cacce13bdedc5b914e3514b7fb30", 549 | "sha256:7d3b1624a953da82ef63462013bbd271d3eb75751489f9807598e8f340bd637e" 550 | ], 551 | "markers": "python_version >= '3.6'", 552 | "version": "==5.0.0" 553 | }, 554 | "wcwidth": { 555 | "hashes": [ 556 | "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784", 557 | "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83" 558 | ], 559 | "version": "==0.2.5" 560 | } 561 | }, 562 | "develop": { 563 | "attrs": { 564 | "hashes": [ 565 | "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1", 566 | "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb" 567 | ], 568 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 569 | "version": "==21.2.0" 570 | }, 571 | "coverage": { 572 | "hashes": [ 573 | "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c", 574 | "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6", 575 | "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45", 576 | "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a", 577 | "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03", 578 | "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529", 579 | "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a", 580 | "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a", 581 | "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2", 582 | "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6", 583 | "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759", 584 | "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53", 585 | "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a", 586 | "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4", 587 | "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff", 588 | "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502", 589 | "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793", 590 | "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb", 591 | "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905", 592 | "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821", 593 | "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b", 594 | "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81", 595 | "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0", 596 | "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b", 597 | "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3", 598 | "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184", 599 | "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701", 600 | "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a", 601 | "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82", 602 | "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638", 603 | "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5", 604 | "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083", 605 | "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6", 606 | "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90", 607 | "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465", 608 | "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a", 609 | "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3", 610 | "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e", 611 | "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066", 612 | "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf", 613 | "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b", 614 | "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae", 615 | "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669", 616 | "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873", 617 | "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b", 618 | "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6", 619 | "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb", 620 | "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160", 621 | "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c", 622 | "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079", 623 | "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d", 624 | "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6" 625 | ], 626 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", 627 | "version": "==5.5" 628 | }, 629 | "flake8": { 630 | "hashes": [ 631 | "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b", 632 | "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907" 633 | ], 634 | "index": "pypi", 635 | "version": "==3.9.2" 636 | }, 637 | "iniconfig": { 638 | "hashes": [ 639 | "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", 640 | "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" 641 | ], 642 | "version": "==1.1.1" 643 | }, 644 | "mccabe": { 645 | "hashes": [ 646 | "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", 647 | "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" 648 | ], 649 | "version": "==0.6.1" 650 | }, 651 | "packaging": { 652 | "hashes": [ 653 | "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5", 654 | "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a" 655 | ], 656 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 657 | "version": "==20.9" 658 | }, 659 | "pluggy": { 660 | "hashes": [ 661 | "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", 662 | "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" 663 | ], 664 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 665 | "version": "==0.13.1" 666 | }, 667 | "py": { 668 | "hashes": [ 669 | "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3", 670 | "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a" 671 | ], 672 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 673 | "version": "==1.10.0" 674 | }, 675 | "pycodestyle": { 676 | "hashes": [ 677 | "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068", 678 | "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef" 679 | ], 680 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 681 | "version": "==2.7.0" 682 | }, 683 | "pyflakes": { 684 | "hashes": [ 685 | "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3", 686 | "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db" 687 | ], 688 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 689 | "version": "==2.3.1" 690 | }, 691 | "pyparsing": { 692 | "hashes": [ 693 | "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", 694 | "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" 695 | ], 696 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 697 | "version": "==2.4.7" 698 | }, 699 | "pytest": { 700 | "hashes": [ 701 | "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b", 702 | "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890" 703 | ], 704 | "index": "pypi", 705 | "version": "==6.2.4" 706 | }, 707 | "pytest-cov": { 708 | "hashes": [ 709 | "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a", 710 | "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7" 711 | ], 712 | "index": "pypi", 713 | "version": "==2.12.1" 714 | }, 715 | "toml": { 716 | "hashes": [ 717 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 718 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 719 | ], 720 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 721 | "version": "==0.10.2" 722 | } 723 | } 724 | } 725 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI - PostgreSQL - Celery - Rabbitmq backend 2 | 3 | This source code implements the following architecture: 4 | 5 |

6 | architecture 7 |

8 | 9 | All the required database endpoints are implemented and tested. These include crud operations for ``dog`` and ``user`` PostgreSQL relations. The asynchronous tasks are queued via one endpoint, and the upload of files to guane internal test server (external API) is implemented as another endpoint. 10 | 11 | This app also executes HTTP requests to another external endpoint located at which returns a message with an URL to a random dog picture. The URL is stored as the field ``picture`` in the ``dog`` relation. 12 | 13 | ## Deploy using Docker 14 | 15 | To deploy this project using docker make sure you have cloned this repository 16 | 17 | ```bash 18 | $ git clone https://github.com/jearistiz/guane-intern-fastapi 19 | ``` 20 | 21 | and installed Docker. 22 | 23 | Now move to the project root directory 24 | 25 | ```bash 26 | $ cd guane-intern-fastapi 27 | ``` 28 | 29 | Unless otherwise stated, all the commands should be executed from the project root directory denoted as ``~/``. 30 | 31 | To run the docker images just prepare your environment variables in the ``~/.env`` file and run 32 | 33 | ```bash 34 | $ docker compose up --build 35 | ``` 36 | 37 | If you have an older Docker version which does not support the ``$ docker compose`` command, make sure you install the ``docker-compose`` CLI, then run 38 | 39 | ```bash 40 | $ docker-compose up --build 41 | ``` 42 | 43 | The docker-compose.yml is configured to create an image of the application named ``application/backend``, an image of PostgreSQL v13.3 –named ``postgres``–, an image of RabbitMQ v3.8 –``rabbitmq``– and an image of Redis v6.2 –``reddis``. To see the application working sound and safe, visit the URI ``0.0.0.0:8080/docs`` or the equivalent URI you defined in the ``~/.env`` file (use the format ``${BACKEND_HOST}:${BACKEND_PORT}/docs``) and start sending HTTP requests to the application via this nice interactive documentation view, brought to us automatically thanks to FastAPI integration with OpenAPI 44 | 45 |

46 | architecture 47 |

48 | 49 | In order to use the POST, PUT or DELETE endpoints you should first authenticate at the top right of the application docs (``0.0.0.0:8080/docs``) in the button that reads ``Authorize``. I have set up this two super users for you to test these endpoints, use the one you feel more comfortable with ;) 50 | 51 | ```md 52 | user: guane 53 | password: ilovethori 54 | ``` 55 | 56 | or 57 | 58 | ```md 59 | user: juanes 60 | password: ilovecharliebot 61 | ``` 62 | 63 | If other fields are required in the authentication form, please leave them empty. 64 | 65 | ## Notes on the database relations 66 | 67 | Please consider the following notes when trying to make requests to the app: 68 | 69 | - The ``dog`` and ``user`` relations are connected by the field ``id_user`` defined in the ``dog`` relation (this is done via foreign key deifinition). Make sure the entity ``user`` with ``id = id_example`` is created before the dog with ``id_user = id_example``. 70 | - If you want to manually define the ``id`` field in the ``user`` and ``dog`` relations, make sure there is no other entity within the relation with the same ``id``. 71 | - The best thing is to just let the backend define the ``id`` fields for you, so just don't include these in the HTTP request when trying to insert or update the entities via POST or PUT methods. 72 | 73 | Thats it for deploying and manually testing the endpoints. 74 | 75 | ## Test the application inside Docker using pytest 76 | 77 | If you want to run the tests inside the docker container, first open another terminal window and get the ```` of the ``app/backend`` container using the command 78 | 79 | ```bash 80 | $ docker ps 81 | ``` 82 | 83 | This command will list information about all your docker images but you are interested only in the one named ``app/backend``. 84 | 85 | Afterwards, run a bash shell using this command 86 | 87 | ```bash 88 | $ docker exec -it bash 89 | ``` 90 | 91 | When you are already inside the container's bash shell, make sure you are located in the ``/app`` directory (you can check this if ``$ pwd`` prints out ``/app``... if not, execute ``$ mv /app``), and execute the following command: 92 | 93 | ```bash 94 | $ python scripts/app/run_tests.py 95 | ``` 96 | 97 | There are some options for this testing initialization script (which underneath uses ``pytest``) such as ``--cov-html`` which will generate an html report of the coverage testing. If you want to see all the options just run ``$ python scripts/app/run_tests.py --help``. And test using your own options. 98 | 99 | ## Deploy without using Docker 100 | 101 | The application can also be deployed locally in your machine. It is a bit more dificult since we need to meet more requirements and setup some stuff first, but soon I will post more info on this here. Soon. 102 | 103 | ### Requirements 104 | 105 | * ``python >= 3.9``: 106 | * ``pipenv``: ``$ pip3 install pipenv`` 107 | * ``postgres >= 13.2``: 108 | * make sure the ``createdb`` and ``pg_ctl`` CLIs are installed using the commands ``which createdb``, etc. 109 | * ``RabbitMQ``: 110 | * make sure the ``rabbitmq-server`` and ``rabbitmqctl`` CLIs are installed. 111 | * ``Redis``: 112 | * make sure the ``celery`` CLI is installed. 113 | 114 | **NOTE:** you may find the scripts used for server initialization invasive. Please take into account that this scripts might start and stop the appropriate servers (postgres, celery, rabbitmq, redis), and they might create and delete users in the mentioned services. Please review the scripts before executing them and execute them only under your own responsibility. 115 | 116 | ## Code quality 117 | 118 | The developement process has been carefully monitored using the sonarcloud engine available at . Flake8 linter has also been used thoroughly for code style and pytest to ensure the code is working as expected. 119 | 120 | ## References 121 | 122 | This app was developed using as main reference [@tiangolo](https://github.com/tiangolo)'s [FastAPI documentation](https://fastapi.tiangolo.com/) and his [Full stack, modern web application generator](https://github.com/tiangolo/full-stack-fastapi-postgresql), which are both distributed under an MIT License. Some parts of this source code are literal code blocks from the cited references. 123 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | # NOTE: DO NOT IMPORT app.main.app here, otherwise, the server initializarion 2 | # script (run_server.py) will possibly fail when local_db option is set to true 3 | 4 | VERSION = '0.1.dev0' 5 | -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/fastapi-python-intern/b3f3ed671bec5fff3e45767a5c6d8e72afbcafd6/app/api/__init__.py -------------------------------------------------------------------------------- /app/api/api.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from app.config import sttgs 4 | from app.api.routers import ( 5 | dogs_router, 6 | users_router, 7 | upload_file_router, 8 | security_router, 9 | tasks_router 10 | ) 11 | 12 | 13 | api_router = APIRouter() 14 | 15 | 16 | api_router.include_router( 17 | security_router, 18 | prefix=sttgs.get('SECURITY_PREFIX', '/security'), 19 | tags=['security'] 20 | ) 21 | api_router.include_router( 22 | tasks_router, 23 | prefix=sttgs.get('CELERY_TASKS_PREFIX', '/tasks'), 24 | tags=['celery tasks'] 25 | ) 26 | api_router.include_router( 27 | dogs_router, 28 | prefix=sttgs.get('DOGS_API_PREFIX', '/dogs'), 29 | tags=['dogs'] 30 | ) 31 | api_router.include_router( 32 | users_router, 33 | prefix=sttgs.get('USERS_API_PREFIX', '/dogs'), 34 | tags=['users'] 35 | ) 36 | api_router.include_router( 37 | upload_file_router, 38 | prefix=sttgs.get('UPLOAD_API_PREFIX', '/upload'), 39 | tags=['upload file'] 40 | ) 41 | -------------------------------------------------------------------------------- /app/api/deps.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | from app.db.session import SessionLocal 3 | 4 | 5 | def get_db() -> Generator: 6 | """Starts and ends session in each route that needs database access. 7 | """ 8 | db = SessionLocal() 9 | try: 10 | yield db 11 | finally: 12 | db.close() 13 | -------------------------------------------------------------------------------- /app/api/routers/__init__.py: -------------------------------------------------------------------------------- 1 | from .dogs import dogs_router 2 | from .users import users_router 3 | from .upload_file import upload_file_router 4 | from .security import security_router 5 | from .tasks import tasks_router 6 | 7 | 8 | __all__ = [ 9 | 'dogs_router', 10 | 'users_router', 11 | 'upload_file_router', 12 | 'security_router', 13 | 'tasks_router', 14 | ] 15 | -------------------------------------------------------------------------------- /app/api/routers/dogs.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi import APIRouter, Depends, HTTPException 4 | from sqlalchemy.orm import Session 5 | from starlette import status 6 | 7 | from app import schemas, crud 8 | from app.api import deps 9 | from app.crud import superuser_crud 10 | from app.utils.http_request import get_dog_picture 11 | 12 | 13 | dogs_router = APIRouter() 14 | 15 | # Web crud was implemented as a wrapper to avoid duplicate code between 16 | # the two main routers (dogs, users) 17 | dog_web_crud = crud.WebCRUDWrapper(crud.dog, enty_name='dog') 18 | 19 | 20 | @dogs_router.get( 21 | '/', 22 | response_model=schemas.Dogs, 23 | name='List of all dogs\' info.' 24 | ) 25 | async def get_dogs( 26 | db: Session = Depends(deps.get_db), 27 | ) -> Any: 28 | """Get a list of all ``dog`` entities. 29 | """ 30 | return dog_web_crud.get_all_entries(db) 31 | 32 | 33 | @dogs_router.get( 34 | '/is_adopted', 35 | response_model=schemas.AdoptedDogs, 36 | name='Adopted dogs\' info.' 37 | ) 38 | async def get_dogs_is_adopted( 39 | db: Session = Depends(deps.get_db) 40 | ) -> Any: 41 | """Get a list of all ``dog`` entities where the flag ``is_adopted`` is 42 | True. 43 | """ 44 | adopted_dogs = crud.dog.get_adopted(db) 45 | if not adopted_dogs: 46 | raise HTTPException( 47 | 400, 48 | detail='No adopted dogs found.' 49 | ) 50 | return {'adopted_dogs': adopted_dogs} 51 | 52 | 53 | @dogs_router.get( 54 | '/{name}', 55 | response_model=schemas.Dog, 56 | name='Dog info by name.' 57 | ) 58 | async def get_dogs_name( 59 | *, 60 | db: Session = Depends(deps.get_db), 61 | name: str 62 | ) -> Any: 63 | """Read one ``dog`` entity based on its name 64 | """ 65 | return dog_web_crud.get_enty_by_name(db, name) 66 | 67 | 68 | @dogs_router.post( 69 | '/{name}', 70 | response_model=schemas.Dog, 71 | name='Save one dog.', 72 | status_code=status.HTTP_201_CREATED, 73 | ) 74 | async def post_dogs_name( 75 | *, 76 | db: Session = Depends(deps.get_db), 77 | dog_info: schemas.DogCreate, 78 | name: str, 79 | current_superuser: schemas.SuperUser = Depends( 80 | superuser_crud.get_current_active_user 81 | ) 82 | ) -> Any: 83 | """Save one ``dog`` entity. Don't include the field `picture` in your 84 | request if you want the backend to fill it with a random dog picture URL 85 | link. 86 | """ 87 | if dog_info.picture is None: 88 | dog_info.picture = get_dog_picture() 89 | return dog_web_crud.post_enty_by_name(db, name=name, enty_info=dog_info) 90 | 91 | 92 | @dogs_router.put( 93 | '/{name}', 94 | response_model=schemas.Dog, 95 | name='Update dog info by name.', 96 | ) 97 | async def put_dogs_name( 98 | *, 99 | db: Session = Depends(deps.get_db), 100 | dog_new_info: schemas.DogUpdate, 101 | name: str, 102 | current_superuser: schemas.SuperUser = Depends( 103 | superuser_crud.get_current_active_user 104 | ) 105 | ) -> Any: 106 | """Update one ``dog`` entity based on its name. 107 | """ 108 | return dog_web_crud.put_enty_by_name( 109 | db, name=name, enty_new_info=dog_new_info 110 | ) 111 | 112 | 113 | @dogs_router.delete( 114 | '/{name}', 115 | response_model=schemas.Dog, 116 | name='Delete dog by name.', 117 | ) 118 | async def delete_dogs_name( 119 | *, 120 | db: Session = Depends(deps.get_db), 121 | name: str, 122 | current_superuser: schemas.SuperUser = Depends( 123 | superuser_crud.get_current_active_user 124 | ) 125 | ) -> Any: 126 | return dog_web_crud.delete_enty_by_name(db, name=name) 127 | -------------------------------------------------------------------------------- /app/api/routers/security.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from datetime import timedelta 3 | 4 | from fastapi import APIRouter, Depends, HTTPException, status 5 | from fastapi.security import OAuth2PasswordRequestForm 6 | 7 | from app import schemas 8 | from app.config import sttgs 9 | from app.core.security.pwd import authenticate_user 10 | from app.core.security.security import auth_header 11 | from app.core.security.token import create_access_token 12 | from app.db.data.superusers_fake_db import superusers_db 13 | 14 | 15 | security_router = APIRouter() 16 | 17 | 18 | @security_router.post("/token", response_model=schemas.Token) 19 | async def login_for_access_token( # noqa 20 | form_data: OAuth2PasswordRequestForm = Depends() 21 | ) -> Any: 22 | """Try to get the token with one of this two superusers: 23 | 24 | user: guane 25 | password: ilovethori 26 | 27 | or 28 | 29 | user: juanes 30 | password: ilovecharliebot 31 | """ 32 | user = authenticate_user( 33 | superusers_db, 34 | form_data.username, 35 | form_data.password 36 | ) 37 | if not user: 38 | raise HTTPException( 39 | status_code=status.HTTP_401_UNAUTHORIZED, 40 | detail="Incorrect username or password", 41 | headers=auth_header, 42 | ) 43 | access_token_expires = timedelta( 44 | minutes=int(sttgs.get('ACCESS_TOKEN_EXPIRE_MINUTES', 15)) 45 | ) 46 | access_token = create_access_token( 47 | data={"sub": user.username}, expires_delta=access_token_expires 48 | ) 49 | return {"access_token": access_token, "token_type": "bearer"} 50 | -------------------------------------------------------------------------------- /app/api/routers/tasks.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Awaitable, Dict 2 | 3 | from fastapi import APIRouter, Depends, HTTPException, Request, status 4 | from celery.result import AsyncResult 5 | 6 | from app import schemas 7 | from app.config import sttgs 8 | from app.crud import superuser_crud 9 | from app.worker.celery_app import celery_app 10 | 11 | 12 | tasks_router = APIRouter() 13 | 14 | 15 | @tasks_router.post( 16 | '/celery_task', 17 | response_model=schemas.CeleryTaskResponse, 18 | status_code=status.HTTP_201_CREATED, 19 | ) 20 | async def celery_task( 21 | task_complexity: int, 22 | request: Request, 23 | current_superuser: schemas.SuperUser = Depends( 24 | superuser_crud.get_current_active_user 25 | ) 26 | ) -> Any: 27 | return await run_task_post_to_uri( 28 | task_complexity=task_complexity, 29 | get_task_result=False, 30 | ) 31 | 32 | 33 | @tasks_router.post( 34 | '/celery_task_not_async', 35 | response_model=schemas.CeleryTaskResponse, 36 | status_code=status.HTTP_201_CREATED, 37 | ) 38 | async def celery_task_not_async( 39 | task_complexity: int, 40 | request: Request, 41 | current_superuser: schemas.SuperUser = Depends( 42 | superuser_crud.get_current_active_user 43 | ) 44 | ) -> Any: 45 | """Same functionality as last endpoint but this one returns the external 46 | server (guane's) response completely at the expense of loosing the async 47 | property of celery because of the call to ``task_result.get()``. Keep in 48 | mind that a request to this endpoint will take at least as many seconds as 49 | the ``task_complexity`` query parameter. 50 | 51 | This one is just for fun, and to test that guane's server is getting the 52 | request and giving us an appropriate response. 53 | 54 | Do not use a query parameter greater than 9, since the endpoint calls 55 | internally ``task_result.get(timeout=10)`` and it would result in a server 56 | error. 57 | """ 58 | return await run_task_post_to_uri( 59 | task_complexity=task_complexity, 60 | get_task_result=True, 61 | get_result_timeout=10.0 62 | ) 63 | 64 | 65 | async def run_task_post_to_uri( 66 | task_complexity: int = 0, 67 | *, 68 | get_task_result: bool, 69 | get_result_timeout: float = 10.0, 70 | ) -> Awaitable[Dict[str, Any]]: 71 | """If ``get_task_result`` is set to ``True``, the async nature of the 72 | celerymtask will be lost, since we make a call to ``task_result.get``. 73 | 74 | ``get_result_timeout`` only makes sense when ``get_task_result`` is set to 75 | true. This is the maximum ammount of time the server will wait for the 76 | task to complete. 77 | """ 78 | response: Dict[str, Any] = { 79 | 'task_complexity': task_complexity 80 | } 81 | query_uri = ( 82 | sttgs.get('GUANE_WORKER_URI') + f'?task_complexity={task_complexity}' 83 | ) 84 | try: 85 | task_result: AsyncResult = celery_app.send_task( 86 | 'app.worker.tasks.post_to_uri_task', 87 | kwargs={'query_uri': query_uri} 88 | ) 89 | # If next code block is executed, the async nature of the task will 90 | # be lost since task_result.get waits until the task is complete. 91 | if get_task_result: 92 | ext_server_response = task_result.get(timeout=get_result_timeout) 93 | if ext_server_response: 94 | response['server_message'] = ext_server_response 95 | except Exception: 96 | response['success'] = False 97 | response['status'] = 'Internal server error' 98 | raise HTTPException(status_code=500, detail=response) 99 | 100 | return response 101 | -------------------------------------------------------------------------------- /app/api/routers/upload_file.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any 3 | 4 | from fastapi import APIRouter, Request, Depends, HTTPException, status 5 | import requests as req 6 | 7 | from app import schemas 8 | from app.config import sttgs 9 | from app.crud import superuser_crud 10 | from app.utils.http_request import post_file_to_uri 11 | from app.utils.paths import join_relative_path 12 | 13 | 14 | upload_file_router = APIRouter() 15 | 16 | 17 | @upload_file_router.post( 18 | '/file-to-guane', 19 | response_model=schemas.UploadFileStatus, 20 | status_code=status.HTTP_201_CREATED, 21 | ) 22 | async def post_file_to_guane( 23 | client_req: Request, 24 | current_superuser: schemas.SuperUser = Depends( 25 | superuser_crud.get_current_active_user 26 | ) 27 | ) -> Any: 28 | """With an empy body request to this endpoint, the api sends a a locally 29 | stored file to a previously defined endpoint (in this case, guane's test 30 | api). 31 | """ 32 | this_file_path = Path(__file__).parent.absolute() 33 | upload_file_path = join_relative_path( 34 | this_file_path, 35 | sttgs.get('UPLOAD_FILE_PATH') 36 | ) 37 | 38 | upload_req = post_file_to_uri( 39 | upload_file_path, 40 | message='Hello, guane. This is Juan Esteban Aristizabal!' 41 | ) 42 | 43 | # If timeout in upload_request 44 | if not isinstance(upload_req, req.Response): 45 | if upload_req: 46 | raise HTTPException( 47 | 502, 48 | detail={ 49 | 'success': False, 50 | 'remote_server_response': None, 51 | 'remote_server_status_code': None, 52 | 'message': upload_req 53 | } 54 | ) 55 | else: 56 | raise HTTPException(502) 57 | 58 | return { 59 | 'success': True if upload_req.status_code == 201 else False, 60 | 'remote_server_response': upload_req.json(), 61 | 'remote_server_status_code': upload_req.status_code 62 | } 63 | -------------------------------------------------------------------------------- /app/api/routers/users.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi import APIRouter, Depends, status 4 | from sqlalchemy.orm import Session 5 | 6 | from app import crud, schemas 7 | from app.api import deps 8 | from app.crud import superuser_crud 9 | 10 | 11 | users_router = APIRouter() 12 | 13 | # Web crud was implemented as a wrapper to avoid duplicate code between 14 | # the two main routers (dogs, users) 15 | user_web_crud = crud.WebCRUDWrapper(crud.user, enty_name='user') 16 | 17 | 18 | @users_router.get( 19 | '/', 20 | response_model=schemas.Users, 21 | name='List all users', 22 | ) 23 | async def get_users( 24 | db: Session = Depends(deps.get_db) 25 | ) -> Any: 26 | return user_web_crud.get_all_entries(db) 27 | 28 | 29 | @users_router.get( 30 | '/{name}', 31 | response_model=schemas.User, 32 | name='User info by name' 33 | ) 34 | async def get_users_name( 35 | *, 36 | db: Session = Depends(deps.get_db), 37 | name: str 38 | ) -> Any: 39 | """Read one ``user`` entity based on its name. 40 | """ 41 | return user_web_crud.get_enty_by_name(db, name) 42 | 43 | 44 | @users_router.post( 45 | '/{name}', 46 | response_model=schemas.User, 47 | name='Create user', 48 | status_code=status.HTTP_201_CREATED, 49 | ) 50 | async def post_users_name( 51 | *, 52 | db: Session = Depends(deps.get_db), 53 | user_info: schemas.UserCreate, 54 | name: str, 55 | current_superuser: schemas.SuperUser = Depends( 56 | superuser_crud.get_current_active_user 57 | ) 58 | ) -> Any: 59 | """Save one ``user`` entity. 60 | """ 61 | return user_web_crud.post_enty_by_name(db, name=name, enty_info=user_info) 62 | 63 | 64 | @users_router.put( 65 | '/{name}', 66 | response_model=schemas.User, 67 | name='Update user by name' 68 | ) 69 | async def put_users_name( 70 | *, 71 | db: Session = Depends(deps.get_db), 72 | user_new_info: schemas.UserUpdate, 73 | name: str, 74 | current_superuser: schemas.SuperUser = Depends( 75 | superuser_crud.get_current_active_user 76 | ) 77 | ) -> Any: 78 | """Update one ``user`` entity based on its name. 79 | """ 80 | return user_web_crud.put_enty_by_name( 81 | db, name=name, enty_new_info=user_new_info 82 | ) 83 | 84 | 85 | @users_router.delete( 86 | '/{name}', 87 | response_model=schemas.User, 88 | name='Delete user by name' 89 | ) 90 | async def delete_users_name( 91 | *, 92 | db: Session = Depends(deps.get_db), 93 | name: str, 94 | current_superuser: schemas.SuperUser = Depends( 95 | superuser_crud.get_current_active_user 96 | ) 97 | ) -> Any: 98 | """Delete one ``user`` entity based on its name. 99 | """ 100 | return user_web_crud.delete_enty_by_name(db, name=name) 101 | -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from dotenv import load_dotenv 5 | 6 | 7 | dotenv_path = Path(__file__).resolve().parent / '..' / '.env' 8 | 9 | load_dotenv(dotenv_path) 10 | 11 | sttgs = os.environ 12 | -------------------------------------------------------------------------------- /app/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/fastapi-python-intern/b3f3ed671bec5fff3e45767a5c6d8e72afbcafd6/app/core/__init__.py -------------------------------------------------------------------------------- /app/core/security/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/fastapi-python-intern/b3f3ed671bec5fff3e45767a5c6d8e72afbcafd6/app/core/security/__init__.py -------------------------------------------------------------------------------- /app/core/security/pwd.py: -------------------------------------------------------------------------------- 1 | from app.core.security.security import pwd_context 2 | from app.crud.superuser_crud import get_user 3 | 4 | 5 | def verify_password(plain_password, hashed_password): 6 | return pwd_context.verify(plain_password, hashed_password) 7 | 8 | 9 | def password_hash(password): 10 | return pwd_context.hash(password) 11 | 12 | 13 | def authenticate_user(fake_db, username: str, password: str): 14 | user = get_user(fake_db, username) 15 | if not user: 16 | return False 17 | if not verify_password(password, user.hashed_password): 18 | return False 19 | return user 20 | -------------------------------------------------------------------------------- /app/core/security/security.py: -------------------------------------------------------------------------------- 1 | from fastapi.security import OAuth2PasswordBearer 2 | from passlib.context import CryptContext 3 | 4 | from app.config import sttgs 5 | 6 | 7 | # Defines the authentication schema 8 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl=sttgs.get('TOKEN_URI')) 9 | 10 | # Handles the passwords 11 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 12 | 13 | # OpenAPI authentication header spec 14 | auth_header = {"WWW-Authenticate": "Bearer"} 15 | -------------------------------------------------------------------------------- /app/core/security/token.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from datetime import datetime, timedelta 3 | 4 | from jose import jwt 5 | 6 | from app.config import sttgs 7 | 8 | 9 | def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): 10 | to_encode = data.copy() 11 | if expires_delta: 12 | expire = datetime.utcnow() + expires_delta 13 | else: 14 | expire = datetime.utcnow() + timedelta(minutes=15) 15 | to_encode.update({"exp": expire}) 16 | encoded_jwt = jwt.encode( 17 | to_encode, 18 | sttgs.get('SECRET_KEY'), 19 | algorithm=sttgs.get('ALGORITHM') 20 | ) 21 | return encoded_jwt 22 | -------------------------------------------------------------------------------- /app/crud/__init__.py: -------------------------------------------------------------------------------- 1 | from .dog_crud import dog 2 | from .user_crud import user 3 | from .web_crud import WebCRUDWrapper 4 | 5 | 6 | __all__ = ['dog', 'user', 'WebCRUDWrapper'] 7 | -------------------------------------------------------------------------------- /app/crud/base.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union 2 | 3 | from fastapi.encoders import jsonable_encoder 4 | from pydantic import BaseModel 5 | from sqlalchemy.orm import Session 6 | 7 | from app.models.base_class import Base 8 | 9 | ModelType = TypeVar("ModelType", bound=Base) 10 | CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) 11 | UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) 12 | 13 | 14 | class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): 15 | def __init__(self, model: Type[ModelType]): 16 | """CRUD object with default methods to Create, Read, Update, 17 | Delete (CRUD). 18 | 19 | **Parameters** 20 | 21 | * `model`: A SQLAlchemy model class 22 | * `schema`: A Pydantic model (schema) class 23 | """ 24 | self.model = model 25 | 26 | def get(self, db: Session, id: Any) -> Optional[ModelType]: 27 | return db.query(self.model).filter(self.model.id == id).first() 28 | 29 | def get_by_name(self, db: Session, *, name_in: str) -> Optional[ModelType]: 30 | """Returns ``None`` when :attr:`CRUDBase.model` does not have attribute 31 | ``name``. 32 | """ 33 | try: 34 | db_obj = ( 35 | db.query(self.model) 36 | .filter(self.model.name == name_in) 37 | .first() 38 | ) 39 | except Exception: 40 | return None 41 | 42 | return db_obj 43 | 44 | def get_multi( 45 | self, db: Session, *, skip: int = 0, limit: Optional[int] = None 46 | ) -> List[ModelType]: 47 | return db.query(self.model).offset(skip).limit(limit).all() 48 | 49 | def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType: 50 | obj_in_data = jsonable_encoder(obj_in) 51 | db_obj = self.model(**obj_in_data) # type: ignore 52 | db.add(db_obj) 53 | db.commit() 54 | db.refresh(db_obj) 55 | return db_obj 56 | 57 | def update( 58 | self, 59 | db: Session, 60 | *, 61 | db_obj: ModelType, 62 | obj_in: Union[UpdateSchemaType, Dict[str, Any]] 63 | ) -> ModelType: 64 | obj_data = jsonable_encoder(db_obj) 65 | if isinstance(obj_in, dict): 66 | update_data = obj_in 67 | else: 68 | update_data = obj_in.dict(exclude_unset=True) 69 | for field in obj_data: 70 | if field in update_data: 71 | setattr(db_obj, field, update_data[field]) 72 | db.add(db_obj) 73 | db.commit() 74 | db.refresh(db_obj) 75 | return db_obj 76 | 77 | def update_by_name( 78 | self, 79 | db: Session, 80 | *, 81 | name_in_db: str, 82 | obj_in: Union[UpdateSchemaType, Dict[str, Any]] 83 | ) -> Optional[ModelType]: 84 | """Returns ``None`` when :attr:`CRUDBase.model` does not have attribute 85 | ``name``. 86 | """ 87 | try: 88 | db_obj = ( 89 | db.query(self.model) 90 | .filter(self.model.name == name_in_db) 91 | .first() 92 | ) 93 | except Exception: 94 | return None 95 | 96 | return self.update(db, db_obj=db_obj, obj_in=obj_in) 97 | 98 | def remove(self, db: Session, *, id: int) -> ModelType: 99 | obj = db.query(self.model).get(id) 100 | db.delete(obj) 101 | db.commit() 102 | return obj 103 | 104 | def remove_one_by_name(self, db: Session, *, name: str) -> ModelType: 105 | """Returns ``None`` when :attr:`CRUDBase.model` does not have attribute 106 | ``name``. 107 | """ 108 | try: 109 | obj = db.query(self.model).filter(self.model.name == name).first() 110 | except Exception: 111 | return None 112 | db.delete(obj) 113 | db.commit() 114 | return obj 115 | -------------------------------------------------------------------------------- /app/crud/dog_crud.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from sqlalchemy.orm import Session 4 | 5 | from app.crud.base import CRUDBase 6 | from app.schemas import DogCreate, DogUpdate 7 | from app.models import Dog 8 | 9 | 10 | class CRUDDog(CRUDBase[Dog, DogCreate, DogUpdate]): 11 | def get_adopted( 12 | self, db: Session, *, skip: int = 0, limit: Optional[int] = None 13 | ) -> List[Dog]: 14 | return ( 15 | db.query(self.model) 16 | .filter(self.model.is_adopted == True) # noqa 17 | .offset(skip) 18 | .limit(limit) 19 | .all() 20 | ) 21 | 22 | 23 | dog = CRUDDog(Dog) 24 | -------------------------------------------------------------------------------- /app/crud/superuser_crud.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends, HTTPException, status 2 | from jose import jwt, JWTError 3 | 4 | from app.config import sttgs 5 | from app.schemas import TokenData, SuperUser, SuperUserInDB 6 | from app.core.security.security import oauth2_scheme, auth_header 7 | from app.db.data.superusers_fake_db import superusers_db 8 | 9 | 10 | def get_user(db, username: str): 11 | if username in db: 12 | user_dict = db[username] 13 | return SuperUserInDB(**user_dict) 14 | 15 | 16 | async def get_current_user(token: str = Depends(oauth2_scheme)): 17 | credentials_exception = HTTPException( 18 | status_code=status.HTTP_401_UNAUTHORIZED, 19 | detail="Could not validate credentials", 20 | headers=auth_header, 21 | ) 22 | try: 23 | payload = jwt.decode( 24 | token, 25 | sttgs.get('SECRET_KEY'), 26 | algorithms=[sttgs.get('ALGORITHM')] 27 | ) 28 | username: str = payload.get("sub") 29 | if username is None: 30 | raise credentials_exception 31 | token_data = TokenData(username=username) 32 | except JWTError: 33 | raise credentials_exception 34 | user = get_user(superusers_db, username=token_data.username) 35 | if user is None: 36 | raise credentials_exception 37 | return user 38 | 39 | 40 | async def get_current_active_user( 41 | current_user: SuperUser = Depends(get_current_user) 42 | ): 43 | if current_user.disabled: 44 | raise HTTPException(status_code=400, detail="Inactive user") 45 | return current_user 46 | -------------------------------------------------------------------------------- /app/crud/user_crud.py: -------------------------------------------------------------------------------- 1 | from app.crud.base import CRUDBase 2 | from app.schemas import UserCreate, UserUpdate 3 | from app.models import User 4 | 5 | 6 | class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]): 7 | pass 8 | 9 | 10 | user = CRUDUser(User) 11 | -------------------------------------------------------------------------------- /app/crud/web_crud.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | 3 | from fastapi import HTTPException 4 | from pydantic import BaseModel 5 | from sqlalchemy.orm import Session 6 | 7 | from app.crud.base import CRUDBase 8 | from app.models.base_class import Base 9 | 10 | 11 | class WebCRUDWrapper: 12 | """Wrapper class to avoid duplicate code in API basic crud operations. 13 | """ 14 | def __init__( 15 | self, 16 | crud: CRUDBase, 17 | *, 18 | enty_name: str 19 | ) -> None: 20 | self.crud = crud 21 | self.enty_name: str = enty_name.lower() 22 | self.enty_name_plural = self.enty_name + 's' 23 | 24 | def get_all_entries(self, db: Session) -> Dict[str, List[Base]]: 25 | """Get all db entries of entity.""" 26 | all_enties = { 27 | self.enty_name_plural: [ 28 | self.crud.model(**entity._asdict()) 29 | for entity in self.crud.get_multi(db) 30 | ] 31 | } 32 | 33 | if all_enties.get(self.enty_name_plural): 34 | return all_enties 35 | else: 36 | raise HTTPException( 37 | 400, 38 | detail=f'No {self.enty_name_plural} found' 39 | ) 40 | 41 | def get_enty_by_name(self, db: Session, name: str) -> Base: 42 | enty_by_name = self.crud.get_by_name(db, name_in=name) 43 | 44 | if not enty_by_name: 45 | raise HTTPException( 46 | 400, 47 | detail=f'{self.enty_name.title()} with name \'{name}\' ' 48 | 'not found.' 49 | ) 50 | 51 | return enty_by_name 52 | 53 | def post_enty_by_name( 54 | self, 55 | db: Session, 56 | *, 57 | name: str, 58 | enty_info: BaseModel 59 | ) -> Base: 60 | try: 61 | created_enty = self.crud.create(db, obj_in=enty_info) 62 | except Exception: 63 | raise HTTPException( 64 | 500, 65 | detail=f'Error while creating {self.enty_name} \'{name}\' in ' 66 | 'database.' 67 | ) 68 | 69 | if not created_enty: 70 | raise HTTPException( 71 | 400, 72 | detail=f'Create query of {self.enty_name} \'{name}\' finished ' 73 | 'but was not saved.' 74 | ) 75 | 76 | return created_enty 77 | 78 | def put_enty_by_name( 79 | self, 80 | db: Session, 81 | *, 82 | name: str, 83 | enty_new_info: BaseModel 84 | ): 85 | try: 86 | updated_enty = self.crud.update_by_name( 87 | db, name_in_db=name, obj_in=enty_new_info 88 | ) 89 | except Exception: 90 | raise HTTPException( 91 | 500, 92 | f'Error while updating {self.enty_name} \'{name}\' in ' 93 | f'database. Probably the {self.enty_name} does not exist in ' 94 | 'database.' 95 | ) 96 | 97 | if not updated_enty: 98 | raise HTTPException( 99 | 400, 100 | f'{self.enty_name.title()} \'{name}\' was not updated.' 101 | ) 102 | 103 | return updated_enty 104 | 105 | def delete_enty_by_name( 106 | self, 107 | db: Session, 108 | *, 109 | name: str 110 | ): 111 | try: 112 | deleted_enty = self.crud.remove_one_by_name(db, name=name) 113 | except Exception: 114 | raise HTTPException( 115 | 500, 116 | f'Error while deleting {self.enty_name} \'{name}\' from ' 117 | f'database. Probably the {self.enty_name} does not exist in ' 118 | 'database.' 119 | ) 120 | 121 | if not deleted_enty: 122 | raise HTTPException( 123 | 400, 124 | f'{self.enty_name.title()} \'{name}\' was not deleted.' 125 | ) 126 | 127 | return deleted_enty 128 | -------------------------------------------------------------------------------- /app/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/fastapi-python-intern/b3f3ed671bec5fff3e45767a5c6d8e72afbcafd6/app/db/__init__.py -------------------------------------------------------------------------------- /app/db/base.py: -------------------------------------------------------------------------------- 1 | from app.models import Base, Dog, User 2 | 3 | 4 | __all__ = ['Base', 'Dog', 'User'] 5 | -------------------------------------------------------------------------------- /app/db/data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/fastapi-python-intern/b3f3ed671bec5fff3e45767a5c6d8e72afbcafd6/app/db/data/__init__.py -------------------------------------------------------------------------------- /app/db/data/superusers_fake_db.py: -------------------------------------------------------------------------------- 1 | """This file is meant to mock a user database and is commited to git history 2 | just for the convenience of the developer reading this code: personal data 3 | should never be published in repositories. 4 | """ 5 | superusers_db = { 6 | 'guane': { 7 | "username": "guane", 8 | "full_name": "guane enterprises", 9 | # password: ilovethori 10 | "hashed_password": "$2b$12$ESVIIGyxIcIPaYsZuSKIFOmqwTMbtePT6GnoKf0ufwDjoietMVauO", # noqa 11 | "disabled": False 12 | }, 13 | 'juanes': { 14 | "username": "juanes", 15 | "full_name": "Juan Esteban", 16 | # password: ilovecharliebot 17 | "hashed_password": "$2b$12$Mky.p4UlRtZAc.1IKQayHO8zJuMf.NoblVAT0xehuj6oANUBbsqZ.", # noqa 18 | "disabled": False 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/db/db_manager.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.engine import Engine 2 | 3 | from app.models.base_class import Base 4 | # We need to import app.db.base in order to appropriately create all database 5 | # tables using init_bd() function 6 | from app.db import base # noqa 7 | from app.db.session import engine 8 | 9 | 10 | def create_all_tables(engine: Engine = engine): 11 | """Creates all database tables if they don't already exist. 12 | """ 13 | Base.metadata.create_all(bind=engine) 14 | 15 | 16 | def drop_all_tables(engine: Engine = engine, *, drop: bool): 17 | if drop: 18 | Base.metadata.drop_all(bind=engine) 19 | -------------------------------------------------------------------------------- /app/db/session.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.orm import sessionmaker 3 | 4 | 5 | from app.config import sttgs 6 | 7 | 8 | # Creates connection to PostgreSQL 9 | engine = create_engine( 10 | sttgs.get('POSTGRES_URI'), 11 | pool_pre_ping=True, 12 | echo=True 13 | ) 14 | 15 | 16 | # Create a local session maker to interact with the db via ORM 17 | SessionLocal = sessionmaker( 18 | autocommit=False, 19 | autoflush=False, 20 | bind=engine 21 | ) 22 | -------------------------------------------------------------------------------- /app/db/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .populate_tables import ( 2 | populate_dog_table, 3 | populate_user_table, 4 | populate_tables_mock_data, 5 | ) 6 | 7 | __all__ = [ 8 | 'populate_dog_table', 9 | 'populate_user_table', 10 | 'populate_tables_mock_data', 11 | ] 12 | -------------------------------------------------------------------------------- /app/db/utils/parse_dicts.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any, Dict 3 | 4 | 5 | def parse_dog_dict( 6 | create_date: datetime, 7 | name: str, 8 | picture: str, 9 | is_adopted: bool, 10 | id_user: int, 11 | *args, 12 | **kwargs 13 | ) -> Dict[str, Any]: 14 | """Dog info contained in a dictionary 15 | """ 16 | return { 17 | 'create_date': create_date, 18 | 'name': name, 19 | 'picture': picture, 20 | 'is_adopted': is_adopted, 21 | 'id_user': id_user, 22 | } 23 | 24 | 25 | def parse_user_dict( 26 | create_date: datetime, 27 | name: str, 28 | last_name: str, 29 | email: str, 30 | *args, 31 | **kwargs 32 | ) -> Dict[str, Any]: 33 | """User info contained in a dictionary 34 | """ 35 | return { 36 | 'create_date': create_date, 37 | 'name': name, 38 | 'last_name': last_name, 39 | 'email': email, 40 | } 41 | -------------------------------------------------------------------------------- /app/db/utils/populate_tables.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from app import crud 4 | from app.schemas import DogInDBBase, UserInDBBase 5 | from app.db.session import SessionLocal 6 | from mock_data.db_test_data import dogs_mock, users_mock 7 | 8 | 9 | def populate_dog_table( 10 | Session=SessionLocal, 11 | *, 12 | dogs_in: List[DogInDBBase] = dogs_mock 13 | ) -> None: 14 | with Session() as db: 15 | for dog_in in dogs_in: 16 | crud.dog.create(db, obj_in=dog_in) 17 | 18 | 19 | def populate_user_table( 20 | Session=SessionLocal, 21 | *, 22 | users_in: List[UserInDBBase] = users_mock 23 | ) -> None: 24 | with Session() as db: 25 | for user_in in users_in: 26 | crud.user.create(db, obj_in=user_in) 27 | 28 | 29 | def populate_tables_mock_data( 30 | populate: bool = False, 31 | Session=SessionLocal, 32 | dogs_in: List[DogInDBBase] = dogs_mock, 33 | users_in: List[UserInDBBase] = users_mock 34 | ) -> None: 35 | """Populates database table with mock data. 36 | """ 37 | if populate: 38 | populate_user_table(Session, users_in=users_in) 39 | populate_dog_table(Session, dogs_in=dogs_in) 40 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi.middleware.cors import CORSMiddleware 3 | from fastapi.middleware.trustedhost import TrustedHostMiddleware 4 | 5 | from app.config import sttgs 6 | from app.api.api import api_router 7 | 8 | 9 | # Main app 10 | app = FastAPI(title=sttgs.get('PROJECT_TITLE')) 11 | 12 | app.add_middleware( 13 | TrustedHostMiddleware, 14 | allowed_hosts=sttgs.get('ALLOWED_HOSTS', ['*']).split(',') 15 | ) 16 | 17 | app.add_middleware( 18 | CORSMiddleware, 19 | allow_credentials=True, 20 | allow_origins=sttgs.get('ALLOWED_ORIGINS', ['*']).split(','), 21 | allow_methods=sttgs.get('ALLOWED_METHODS', ['*']).split(','), 22 | allow_headers=sttgs.get('ALLOWED_HEADERS', ['*']).split(',') 23 | ) 24 | 25 | app.include_router(api_router, prefix='/api') 26 | -------------------------------------------------------------------------------- /app/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .dog import Dog 2 | from .user import User 3 | from .base_class import Base 4 | 5 | __all__ = ['Base', 'Dog', 'User'] 6 | -------------------------------------------------------------------------------- /app/models/base_class.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | from sqlalchemy import inspect 4 | from sqlalchemy.ext.declarative import as_declarative, declared_attr 5 | 6 | 7 | @as_declarative() 8 | class Base: 9 | """ORM base class""" 10 | id: Any 11 | __name__: str 12 | 13 | # Generate __tablename__ automatically 14 | @declared_attr 15 | def __tablename__(cls) -> str: 16 | return cls.__name__.lower() 17 | 18 | def _asdict(self) -> Dict[str, Any]: 19 | return { 20 | c.key: getattr(self, c.key) 21 | for c in inspect(self).mapper.column_attrs 22 | } 23 | -------------------------------------------------------------------------------- /app/models/dog.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String, Boolean, DateTime 2 | from sqlalchemy.orm import relationship 3 | from sqlalchemy.sql.schema import ForeignKey 4 | 5 | from app.models.base_class import Base 6 | 7 | 8 | class Dog(Base): 9 | id = Column(Integer, primary_key=True, index=True) 10 | create_date = Column(DateTime, index=True) 11 | name = Column(String, index=True) 12 | picture = Column(String, index=True) 13 | is_adopted = Column(Boolean, index=True) 14 | id_user = Column(Integer, ForeignKey('user.id')) 15 | 16 | # ORM relationship between Dog and User entity 17 | user = relationship('User', back_populates='dogs') 18 | -------------------------------------------------------------------------------- /app/models/user.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String, DateTime 2 | from sqlalchemy.orm import relationship 3 | 4 | from app.models.base_class import Base 5 | 6 | 7 | class User(Base): 8 | id = Column(Integer, primary_key=True, index=True) 9 | create_date = Column(DateTime, index=True) 10 | name = Column(String, index=True) 11 | last_name = Column(String, index=True) 12 | email = Column(String, index=True) 13 | 14 | # ORM relationship between User and Dog entity 15 | dogs = relationship('Dog', back_populates='user') 16 | -------------------------------------------------------------------------------- /app/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | from app.schemas.dog import ( 2 | DogBase, DogCreate, DogUpdate, DogInDBBase, Dog, Dogs, AdoptedDogs 3 | ) 4 | from app.schemas.user import ( 5 | UserBase, UserCreate, UserUpdate, UserInDBBase, User, Users 6 | ) 7 | from app.schemas.upload import UploadFileStatus 8 | from app.schemas.security import ( 9 | Token, 10 | TokenData, 11 | SuperUser, 12 | SuperUserInDB, 13 | ) 14 | from app.schemas.tasks import CeleryTaskResponse 15 | 16 | __all__ = [ 17 | 'DogBase', 18 | 'DogCreate', 19 | 'DogUpdate', 20 | 'DogInDBBase', 21 | 'Dog', 22 | 'Dogs', 23 | 'AdoptedDogs', 24 | 'UserBase', 25 | 'UserCreate', 26 | 'UserUpdate', 27 | 'UserInDBBase', 28 | 'User', 29 | 'Users', 30 | 'UploadFileStatus', 31 | 'Token', 32 | 'TokenData', 33 | 'SuperUser', 34 | 'SuperUserInDB', 35 | 'CeleryTaskResponse', 36 | ] 37 | -------------------------------------------------------------------------------- /app/schemas/base_config.py: -------------------------------------------------------------------------------- 1 | class ConfigBase: 2 | # extra = 'forbid' 3 | pass 4 | -------------------------------------------------------------------------------- /app/schemas/dog.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List, Optional 3 | 4 | from pydantic import BaseModel 5 | 6 | from app.schemas.base_config import ConfigBase 7 | 8 | 9 | # Shared properties 10 | class DogBase(BaseModel): 11 | name: Optional[str] 12 | picture: Optional[str] 13 | is_adopted: Optional[bool] 14 | id_user: Optional[int] 15 | 16 | class Config(ConfigBase): 17 | pass 18 | 19 | 20 | class DogCreate(DogBase): 21 | create_date: Optional[datetime] = datetime.utcnow() 22 | 23 | 24 | class DogUpdate(DogBase): 25 | pass 26 | 27 | 28 | # Properties shared by DB models 29 | class DogInDBBase(DogBase): 30 | id: Optional[int] 31 | create_date: Optional[datetime] = datetime.utcnow() 32 | name: str 33 | picture: Optional[str] 34 | is_adopted: Optional[bool] 35 | id_user: Optional[int] 36 | 37 | class Config: 38 | orm_mode = True 39 | 40 | 41 | # Properties to return in HTTP response 42 | class Dog(DogInDBBase): 43 | pass 44 | 45 | 46 | class Dogs(BaseModel): 47 | dogs: List[Dog] 48 | 49 | 50 | class AdoptedDogs(BaseModel): 51 | adopted_dogs: List[Dog] 52 | -------------------------------------------------------------------------------- /app/schemas/security.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class Token(BaseModel): 7 | access_token: str 8 | token_type: str 9 | 10 | 11 | class TokenData(BaseModel): 12 | username: Optional[str] = None 13 | 14 | 15 | class SuperUser(BaseModel): 16 | username: str 17 | full_name: Optional[str] = None 18 | disabled: Optional[bool] = None 19 | 20 | 21 | class SuperUserInDB(SuperUser): 22 | hashed_password: str 23 | -------------------------------------------------------------------------------- /app/schemas/tasks.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | from pydantic import BaseModel 3 | 4 | 5 | class CeleryTaskResponse(BaseModel): 6 | task_complexity: int 7 | status: str = 'Successfully submitted the task.' 8 | server_message: Optional[Any] 9 | success: bool = True 10 | -------------------------------------------------------------------------------- /app/schemas/upload.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class UploadFileStatus(BaseModel): 7 | success: bool 8 | remote_server_response: Optional[dict] 9 | remote_server_status_code: Optional[int] 10 | message: Optional[str] 11 | -------------------------------------------------------------------------------- /app/schemas/user.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List, Optional 3 | 4 | from pydantic import BaseModel, EmailStr 5 | 6 | from app.schemas.base_config import ConfigBase 7 | 8 | 9 | # Shared properties 10 | class UserBase(BaseModel): 11 | name: Optional[str] 12 | last_name: Optional[str] 13 | email: Optional[EmailStr] 14 | 15 | class Config(ConfigBase): 16 | pass 17 | 18 | 19 | class UserCreate(UserBase): 20 | create_date: Optional[datetime] = datetime.utcnow() 21 | 22 | 23 | class UserUpdate(UserBase): 24 | pass 25 | 26 | 27 | # Properties shared by DB models 28 | class UserInDBBase(UserBase): 29 | id: Optional[int] 30 | create_date: Optional[datetime] = datetime.utcnow() 31 | name: str 32 | last_name: str 33 | email: EmailStr 34 | 35 | class Config: 36 | orm_mode = True 37 | 38 | 39 | # Properties to return in HTTP response 40 | class User(UserInDBBase): 41 | pass 42 | 43 | 44 | class Users(BaseModel): 45 | users: List[User] 46 | -------------------------------------------------------------------------------- /app/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/fastapi-python-intern/b3f3ed671bec5fff3e45767a5c6d8e72afbcafd6/app/utils/__init__.py -------------------------------------------------------------------------------- /app/utils/http_request.py: -------------------------------------------------------------------------------- 1 | import os 2 | from shutil import copyfileobj 3 | from typing import Any, Dict, List, Optional, Union 4 | from pathlib import Path 5 | 6 | import requests as req 7 | 8 | from app.config import sttgs 9 | 10 | 11 | req_timeout = int(sttgs.get('REQUESTS_TIMEOUT', 20)) 12 | 13 | 14 | def get_dog_picture( 15 | api_uri: Optional[str] = sttgs.get('DOG_API_URI') 16 | ) -> Optional[str]: 17 | """Returns None if an exception occurs. 18 | """ 19 | if api_uri: 20 | 21 | try: 22 | r = req.get(api_uri, timeout=req_timeout) 23 | except req.exceptions.Timeout: 24 | return time_out_message(api_uri, req_timeout) 25 | except Exception: 26 | return None 27 | 28 | data_json = r.json() 29 | 30 | if (not r.status_code == 200) or (not isinstance(data_json, dict)): 31 | return None 32 | 33 | if data_json.get('status') == 'success': 34 | return data_json.get('message') 35 | 36 | return None 37 | 38 | 39 | def post_file_to_uri( 40 | upload_file_path: Path, 41 | uri: str = sttgs.get('UPLOAD_FILE_URI'), 42 | file_content: str = 'image/png', 43 | *, 44 | message: str 45 | ) -> Union[req.Response, str]: 46 | """Post a file to uri.""" 47 | # if file does not exist, post a text file 48 | os.makedirs(upload_file_path.parent, exist_ok=True) 49 | if not os.path.isfile(upload_file_path): 50 | upload_file_path = upload_file_path.parent / 'hello_guane.txt' 51 | file_content = 'text/plain' 52 | message = 'Original file was replaced by api.' 53 | with open(upload_file_path, 'w') as save_file: 54 | save_file.write('Hello guane, this is Juan Esteban Aristizábal!') 55 | 56 | # Read file and upload it to uri it using requests library 57 | with open(upload_file_path, 'rb') as payload: 58 | files_to_upload = { 59 | 'file': ( 60 | upload_file_path.name, 61 | payload, 62 | file_content, 63 | {'message': message} 64 | ) 65 | } 66 | try: 67 | request = req.post( 68 | uri, 69 | files=files_to_upload, 70 | timeout=req_timeout, 71 | ) 72 | except req.exceptions.Timeout: 73 | return time_out_message(uri, req_timeout) 74 | 75 | # Save a copy of the file just to verify that the uploaded object was 76 | # correctly read 77 | save_file_copy_path = ( 78 | upload_file_path.parent / ('2-' + upload_file_path.name) 79 | ) 80 | with open(save_file_copy_path, 'wb') as save_file_copy: 81 | payload.seek(0) 82 | save_file_copy.seek(0) 83 | copyfileobj(payload, save_file_copy) 84 | save_file_copy.truncate() 85 | 86 | return request 87 | 88 | 89 | def post_to_uri( 90 | api_uri: str, 91 | message: Dict[str, Any], 92 | expected_status_codes: List[int] = [200, 201] 93 | ) -> Optional[req.Response]: 94 | try: 95 | response = req.post(api_uri, data=message, timeout=req_timeout) 96 | except req.exceptions.Timeout: 97 | raise req.exceptions.Timeout(time_out_message(api_uri, req_timeout)) 98 | 99 | data_json = response.json() 100 | 101 | status_code_is_not_expected = ( 102 | response.status_code not in expected_status_codes 103 | ) 104 | 105 | if status_code_is_not_expected or (not isinstance(data_json, dict)): 106 | return None 107 | 108 | return response 109 | 110 | 111 | def time_out_message(server, secs: int): 112 | return f'The request to {server} timed out after {secs} seconds.' 113 | 114 | 115 | example_dog_urls = [ 116 | "https://images.dog.ceo/breeds/retriever-golden/nina.jpg", 117 | "https://images.dog.ceo/breeds/papillon/n02086910_1613.jpg", 118 | "https://images.dog.ceo/breeds/buhund-norwegian/hakon1.jpg", 119 | "https://images.dog.ceo/breeds/terrier-toy/n02087046_4409.jpg", 120 | ] 121 | -------------------------------------------------------------------------------- /app/utils/paths.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | def join_relative_path(path: Path, rel_path: str) -> Path: 5 | for node in rel_path.split('/'): 6 | path /= node 7 | return path 8 | -------------------------------------------------------------------------------- /app/worker/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/fastapi-python-intern/b3f3ed671bec5fff3e45767a5c6d8e72afbcafd6/app/worker/__init__.py -------------------------------------------------------------------------------- /app/worker/celery_app.py: -------------------------------------------------------------------------------- 1 | from celery import Celery 2 | 3 | from app.config import sttgs 4 | 5 | 6 | celery_app = Celery( 7 | 'celery_worker', 8 | broker=sttgs['RABBITMQ_URI'], 9 | backend=sttgs['CELERY_BAKCEND_URI'], 10 | ) 11 | 12 | celery_app.autodiscover_tasks(['app.worker']) 13 | -------------------------------------------------------------------------------- /app/worker/tasks.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Any 2 | 3 | from app.config import sttgs 4 | from app.worker.celery_app import celery_app 5 | from app.utils.http_request import post_to_uri 6 | 7 | 8 | @celery_app.task( 9 | bind=True, 10 | acks_late=True, 11 | retry_kwargs={'max_retries': 2}, 12 | ) 13 | def post_to_uri_task( 14 | self, 15 | query_uri: str = sttgs['GUANE_WORKER_URI'] + '?task_complexity=0', 16 | message: Dict[str, Any] = {}, 17 | expected_status_codes: List[int] = [201, 200], 18 | ) -> Dict[str, Any]: 19 | try: 20 | response = post_to_uri( 21 | query_uri, 22 | message, 23 | expected_status_codes, 24 | ) 25 | except Exception as e: 26 | self.retry(countdown=3, exc=e) 27 | 28 | return {'status_code': response.status_code, 'data': dict(response.json())} 29 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.5" 2 | services: 3 | backend_app: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | container_name: backend_app 8 | stop_signal: SIGINT 9 | env_file: .env 10 | image: app/backend 11 | depends_on: 12 | - postgres 13 | - celery 14 | ports: 15 | - "${BACKEND_PORT}:${BACKEND_PORT}" 16 | volumes: 17 | - ./:/app 18 | 19 | postgres: 20 | container_name: postgres 21 | image: postgres:13.3 22 | restart: always 23 | env_file: .env 24 | environment: 25 | POSTGRES_USER: ${POSTGRES_USER} 26 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 27 | POSTGRES_DB: ${POSTGRES_DB} 28 | ports: 29 | - ${POSTGRES_PORT}:${POSTGRES_PORT} 30 | 31 | rabbitmq: 32 | image: rabbitmq:3.8 33 | container_name: rabbitmq 34 | env_file: .env 35 | environment: 36 | RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER} 37 | RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS} 38 | RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_DEFAULT_VHOST} 39 | ports: 40 | - "${RABBITMQ_PORT}:${RABBITMQ_PORT}" 41 | - "${RABBITMQ_PORT_2}:${RABBITMQ_PORT_2}" 42 | 43 | redis: 44 | image: redis:6.2 45 | container_name: redis 46 | -------------------------------------------------------------------------------- /img/2-guane-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/fastapi-python-intern/b3f3ed671bec5fff3e45767a5c6d8e72afbcafd6/img/2-guane-logo.png -------------------------------------------------------------------------------- /img/arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/fastapi-python-intern/b3f3ed671bec5fff3e45767a5c6d8e72afbcafd6/img/arch.png -------------------------------------------------------------------------------- /img/docs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/fastapi-python-intern/b3f3ed671bec5fff3e45767a5c6d8e72afbcafd6/img/docs.png -------------------------------------------------------------------------------- /img/guane-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/fastapi-python-intern/b3f3ed671bec5fff3e45767a5c6d8e72afbcafd6/img/guane-logo.png -------------------------------------------------------------------------------- /mock_data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/fastapi-python-intern/b3f3ed671bec5fff3e45767a5c6d8e72afbcafd6/mock_data/__init__.py -------------------------------------------------------------------------------- /mock_data/db_test_data.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from app.schemas import DogInDBBase, UserInDBBase 4 | from app.db.utils.parse_dicts import parse_dog_dict, parse_user_dict 5 | from app.utils.http_request import example_dog_urls 6 | 7 | # Random date 8 | date = str(datetime(2021, 5, 27, 3, 54, 58, 217637)) 9 | 10 | dogs_info = [ 11 | [date, 'Guane', example_dog_urls[0], False, None], 12 | [date, 'Thori', example_dog_urls[1], False, None], 13 | [date, 'CharlieBot', example_dog_urls[2], True, 1], 14 | [date, 'Fuelai', example_dog_urls[3], True, 2], 15 | ] 16 | 17 | dogs_mock_dicts = [ 18 | parse_dog_dict(*dog_info) for dog_info in dogs_info 19 | ] 20 | 21 | adopted_dogs_dicts = [ 22 | dog_info for dog_info in dogs_mock_dicts 23 | if dog_info.get('is_adopted', None) 24 | ] 25 | 26 | dogs_mock = [ 27 | DogInDBBase(**dog_dict) for dog_dict in dogs_mock_dicts 28 | ] 29 | 30 | users_info = [ 31 | [date, 'Guane1', 'Enterprises', 'info@guane.com.co'], 32 | [date, 'Guane2', 'CharlieBot', 'CharlieBot@guane.com.co'], 33 | [date, 'Guane3', 'Thori', 'Thori@guane.com.co'], 34 | [date, 'Guane4', 'Fuelai', 'Fuelai@guane.com.co'], 35 | ] 36 | 37 | users_mock_dicts = [ 38 | parse_user_dict(*user_info) for user_info in users_info 39 | ] 40 | 41 | users_mock = [ 42 | UserInDBBase(**user_dict) for user_dict in users_mock_dicts 43 | ] 44 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # These requirements were autogenerated by pipenv 3 | # To regenerate from the project's Pipfile, run: 4 | # 5 | # pipenv lock --requirements --dev 6 | # 7 | 8 | # Note: in pipenv 2020.x, "--dev" changed to emit both default and development 9 | # requirements. To emit only development requirements, pass "--dev-only". 10 | 11 | -i https://pypi.org/simple 12 | -e . 13 | amqp==5.0.6; python_version >= '3.6' 14 | asgiref==3.3.4; python_version >= '3.6' 15 | attrs==21.2.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' 16 | bcrypt==3.2.0 17 | billiard==3.6.4.0 18 | celery==5.1.0 19 | certifi==2021.5.30 20 | cffi==1.14.5 21 | chardet==4.0.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' 22 | click-didyoumean==0.0.3 23 | click-plugins==1.1.1 24 | click-repl==0.2.0 25 | click==7.1.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' 26 | coverage==5.5; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4' 27 | cryptography==3.4.7 28 | dnspython==2.1.0; python_version >= '3.6' 29 | ecdsa==0.17.0; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3' 30 | email-validator==1.1.2 31 | fastapi==0.65.2 32 | flake8==3.9.2 33 | greenlet==1.1.0; python_version >= '3' 34 | h11==0.12.0; python_version >= '3.6' 35 | idna==2.10; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 36 | iniconfig==1.1.1 37 | kombu==5.1.0; python_version >= '3.6' 38 | mccabe==0.6.1 39 | packaging==20.9; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 40 | passlib[bcrypt]==1.7.4 41 | pluggy==0.13.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 42 | prompt-toolkit==3.0.18; python_full_version >= '3.6.1' 43 | psycopg2-binary==2.8.6 44 | py==1.10.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 45 | pyasn1==0.4.8 46 | pycodestyle==2.7.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 47 | pycparser==2.20; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 48 | pydantic==1.8.2; python_full_version >= '3.6.1' 49 | pyflakes==2.3.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 50 | pyparsing==2.4.7; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3' 51 | pytest-cov==2.12.1 52 | pytest==6.2.4 53 | python-dotenv==0.17.1 54 | python-jose[cryptography]==3.3.0 55 | python-multipart==0.0.5 56 | pytz==2021.1 57 | redis==3.5.3 58 | requests==2.25.1 59 | rsa==4.7.2; python_version >= '3.5' and python_version < '4' 60 | six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 61 | sqlalchemy==1.4.18 62 | starlette==0.14.2; python_version >= '3.6' 63 | toml==0.10.2; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3' 64 | typer==0.3.2 65 | typing-extensions==3.10.0.0 66 | urllib3==1.26.5; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4' 67 | uvicorn==0.14.0 68 | vine==5.0.0; python_version >= '3.6' 69 | wcwidth==0.2.5 70 | -------------------------------------------------------------------------------- /scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/fastapi-python-intern/b3f3ed671bec5fff3e45767a5c6d8e72afbcafd6/scripts/__init__.py -------------------------------------------------------------------------------- /scripts/app/rebuild_venv.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | pipenv --rm 4 | pipenv update --dev 5 | pipenv lock --dev -r > requirements.txt 6 | pipenv run tests 7 | -------------------------------------------------------------------------------- /scripts/app/run_tests.py: -------------------------------------------------------------------------------- 1 | """Testing script for FastAPI app. 2 | 3 | From the root of the project run 4 | ``$ pipenv run python scripts/app/run_tests.py --help`` 5 | to see the options. 6 | """ 7 | import os 8 | import py 9 | 10 | import pytest 11 | from typer import Typer, Option 12 | 13 | from scripts.utils._manage_services import setup_services, teardown_services 14 | 15 | 16 | tests_cli = Typer() 17 | 18 | 19 | docker_help = ( 20 | 'Use this flag when running inside the docker container.' 21 | 'This ensures that a valid $POSGRES_URI is parsed from the ``~/.env`` ' 22 | 'file.' 23 | ) 24 | repopulate_tables_help = ( 25 | 'When the --docker option is set to true, the tables will be empty after ' 26 | 'the tests. If you want to repopulate the tables with mock data, use this ' 27 | 'option. This option only works together with --docker option.' 28 | ) 29 | debug_celery_help = ( 30 | 'Set to True if you want to see calery debug messages in your terminal ' 31 | 'session.' 32 | ) 33 | cov_help = 'Show coverage of tests with target directory app/' 34 | cov_html_help = ( 35 | 'Print coverage and generate html files to see detail of coverage.' 36 | ) 37 | print_all_help = 'Print all stdout from the source code that is being tested.' 38 | collect_only_help = 'Only collect tests, don\'t execute them.' 39 | override_options_help = ( 40 | '(TEXT should be quoted). Use this argument to override all other pytest ' 41 | 'options in this CLI, except --docker --server_running and ' 42 | '--repopulate_tables. Usage examples: \n' 43 | '``$ python run_tests.py --docker --override-options "--fixtures"``\n' 44 | '``$ python run_tests.py --no-docker --override-options "--ff"``.\n' 45 | 'Run ``$ pytest --help`` to see more pytest options. Note that some pytest' 46 | 'options are not compatible withthis CLI.' 47 | ) 48 | 49 | 50 | @tests_cli.command() 51 | def run_tests( 52 | docker: bool = Option(True, help=docker_help), 53 | repopulate_tables: bool = Option(True, help=repopulate_tables_help), 54 | debug_celery: bool = Option(False, help=debug_celery_help), 55 | cov: bool = Option(True, help=cov_help), 56 | cov_html: bool = Option(False, help=cov_html_help), 57 | print_all: bool = Option(False, help=print_all_help), 58 | collect_only: bool = Option(False, help=collect_only_help), 59 | override_options: str = Option('', help=override_options_help) 60 | ) -> None: 61 | 62 | if not docker: 63 | from app.config import sttgs 64 | # Set env postgres URI 65 | os.environ['POSTGRES_TESTS_URI'] = sttgs.get( 66 | 'POSTGRES_LOCAL_TESTS_URI' 67 | ) 68 | 69 | postgres_datadir = '/usr/local/var/postgres' 70 | 71 | services_processes = setup_services( 72 | postgres_datadir=postgres_datadir, 73 | celery_worker=True, 74 | debug_celery_worker=debug_celery 75 | ) 76 | 77 | rabbitmq_server_process = services_processes[0] 78 | redis_server_process = services_processes[1] 79 | celery_worker_process = services_processes[2] 80 | 81 | # First pytest_args element: tests directory 82 | pytest_args = ["tests"] 83 | 84 | if override_options: 85 | pytest_args += override_options.split(' ') 86 | 87 | else: 88 | if print_all: 89 | pytest_args.append("--capture=tee-sys") 90 | 91 | if (cov and cov_html) or cov_html: 92 | pytest_args += ["--cov-report", "html", "--cov", "app"] 93 | 94 | elif cov and not cov_html: 95 | pytest_args += ["--cov", "app"] 96 | 97 | if collect_only: 98 | pytest_args.append('--collect-only') 99 | 100 | # Helps to capture the text "internally printed" by pytest when called 101 | # via pytest.main() 102 | capture = py.io.StdCapture() 103 | 104 | # Run tests 105 | pytest.main(pytest_args) 106 | 107 | # Docker uses the same database for testing and for server deployment 108 | # we need to recreate the tables and repopulate them 109 | if docker: 110 | from app.db.db_manager import create_all_tables 111 | from app.db.utils.populate_tables import populate_tables_mock_data 112 | create_all_tables() 113 | populate_tables_mock_data(populate=repopulate_tables) 114 | 115 | # Print all pytest output 116 | std, _ = capture.reset() 117 | print(std) 118 | 119 | # Terminate local (as opposed to docker) processes 120 | if not docker: 121 | teardown_services( 122 | rabbitmq_server_process, 123 | redis_server_process, 124 | celery_worker_process=celery_worker_process, 125 | postgres_datadir=postgres_datadir, 126 | ) 127 | 128 | 129 | if __name__ == '__main__': 130 | tests_cli() 131 | -------------------------------------------------------------------------------- /scripts/db/drop_all_db_tables.py: -------------------------------------------------------------------------------- 1 | from app.db.db_manager import drop_all_tables 2 | 3 | 4 | if __name__ == '__main__': 5 | drop_all_tables(drop=True) 6 | -------------------------------------------------------------------------------- /scripts/db/drop_all_test_db_tables.py: -------------------------------------------------------------------------------- 1 | from app.db.db_manager import drop_all_tables 2 | from tests.mock.db_session import test_engine 3 | 4 | 5 | if __name__ == '__main__': 6 | drop_all_tables(engine=test_engine, drop=True) 7 | -------------------------------------------------------------------------------- /scripts/docker/force-prune-build-reqs-build-docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker system prune -f -a 4 | pipenv update --dev 5 | pipenv lock --dev -r > requirements.txt 6 | docker compose up --build 7 | -------------------------------------------------------------------------------- /scripts/docker/force-prune-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker system prune -f -a 4 | docker compose up --build 5 | -------------------------------------------------------------------------------- /scripts/docker/prune-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker system prune -a 4 | pipenv update --dev 5 | pipenv lock --dev -r > requirements.txt 6 | docker compose up --build 7 | -------------------------------------------------------------------------------- /scripts/docker/rebuild-venv-force-prune-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sh scripts/app/rebuild_venv.sh 4 | sh scripts/docker/force-prune-build.sh 5 | -------------------------------------------------------------------------------- /scripts/server/reset_rabbitmq.py: -------------------------------------------------------------------------------- 1 | from scripts.utils._rabbitmq import rabbitmq_reset_and_shut_down_server 2 | 3 | 4 | if __name__ == '__main__': 5 | rabbitmq_reset_and_shut_down_server() 6 | -------------------------------------------------------------------------------- /scripts/server/run_server.py: -------------------------------------------------------------------------------- 1 | """Uvicorn server initialization script. 2 | 3 | Run ``python run_server.py --help`` to see the options. 4 | """ 5 | import os 6 | from typing import Optional 7 | 8 | import uvicorn 9 | from typer import Typer, Option 10 | 11 | from scripts.utils._manage_services import setup_services, teardown_services 12 | from scripts.utils._celery import start_celery_worker 13 | 14 | 15 | cli_app = Typer() 16 | 17 | 18 | port_help = ( 19 | 'Set the port of the uvicorn server.' 20 | ) 21 | docker_help = ( 22 | 'Use this option to run the server in the Docker container. This will ' 23 | 'setup the PostgreSQL server using $POSTGRES_URI instead of ' 24 | '$POSTGRES_LOCAL_URI' 25 | ) 26 | populate_tables_help = ( 27 | 'Fill the and PostgreSQL tables with mock data stored inside ' 28 | '``mock_data.db_test_data`` module.' 29 | ) 30 | drop_tables_help = ( 31 | 'After the server is shut down, drop all tables inside PostgreSQL ' 32 | 'database.' 33 | ) 34 | auto_reload_server_help = ( 35 | 'Equivalent to --reload flag in uvicorn server CLI. WARNING: ' 36 | 'if you use this option in the docker container this script will not ' 37 | 'gracefully stop the Celery worker process behind the application. ' 38 | 'If you use it together with --drop-tables tables won\'t be dropped ' 39 | 'either.' 40 | ) 41 | debug_celery_help = ( 42 | 'Set to True if you want to see calery debug messages in your terminal ' 43 | 'session.' 44 | ) 45 | 46 | 47 | @cli_app.command() 48 | def run_uvicorn_server( 49 | docker: bool = Option(True, help=docker_help), 50 | port: Optional[int] = Option(None, help=port_help), 51 | populate_tables: bool = Option(True, help=populate_tables_help), 52 | drop_tables: bool = Option(True, help=drop_tables_help), 53 | auto_reload_server: bool = Option(False, help=drop_tables_help), 54 | debug_celery: bool = Option(False, help=debug_celery_help) 55 | ) -> None: 56 | """Run the FastAPI app using an uvicorn server, optionally setting up and 57 | tearing down some other overheads such as PostgreSQL db, Celery worker, 58 | RabbitMQ server, Redis server, etc. 59 | """ 60 | from app.config import sttgs 61 | 62 | # Setup local environment (as opposed to docker). Tested on MacOS v11.2.3 63 | if not docker: 64 | # Set env postgres URI 65 | os.environ['POSTGRES_URI'] = sttgs.get('POSTGRES_LOCAL_URI') 66 | 67 | postgres_datadir = '/usr/local/var/postgres' 68 | 69 | # The celery worker must be initialized every single time, not just if 70 | # it is a 'local' deploy, we initialize it outside this if statement 71 | rabbitmq_server_process, redis_server_process, _ = setup_services( 72 | postgres_datadir=postgres_datadir, 73 | celery_worker=False 74 | ) 75 | 76 | # Start celery worker 77 | celery_worker_process = start_celery_worker(debug=debug_celery) 78 | 79 | # This dependencies need to be imported here so that the sqlAlchemy engine 80 | # is created with the correct uri (previously modified by local_db 81 | # oprtion). If they are imported at the beggining of the script, the 82 | # dependencies inside the import statements will make the server to be run 83 | # using the wrong URI 84 | from app.db.db_manager import create_all_tables, drop_all_tables 85 | from app.db.utils import populate_tables_mock_data 86 | 87 | # Tables need to be created, always 88 | create_all_tables() 89 | 90 | # Optionally populate tables 91 | populate_tables_mock_data(populate=populate_tables) 92 | 93 | backend_port = port if port else sttgs.get('BACKEND_PORT', 8080) 94 | 95 | # Run server 96 | uvicorn.run( 97 | "app.main:app", 98 | host=sttgs.get('BACKEND_HOST', '0.0.0.0'), 99 | port=int(backend_port), 100 | reload=auto_reload_server, 101 | debug=auto_reload_server, 102 | workers=int(sttgs.get('SERVER_WORKERS', 1)), 103 | ) 104 | 105 | # Optionally drop all postgres tables 106 | drop_all_tables(drop=drop_tables) 107 | 108 | # Always terminate celery worker instance 109 | celery_worker_process.terminate() 110 | 111 | # Terminate local (as opposed to docker) processes 112 | if not docker: 113 | teardown_services( 114 | rabbitmq_server_process, 115 | redis_server_process, 116 | celery_worker_process=None, 117 | postgres_datadir=postgres_datadir, 118 | ) 119 | 120 | 121 | if __name__ == '__main__': 122 | cli_app() 123 | -------------------------------------------------------------------------------- /scripts/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/fastapi-python-intern/b3f3ed671bec5fff3e45767a5c6d8e72afbcafd6/scripts/utils/__init__.py -------------------------------------------------------------------------------- /scripts/utils/_celery.py: -------------------------------------------------------------------------------- 1 | from subprocess import Popen 2 | 3 | 4 | def start_celery_worker( 5 | module: str = 'app.worker.celery_app:celery_app', 6 | debug: bool = False 7 | ) -> Popen: 8 | celery_command = ['celery', '-A', module, 'worker'] 9 | celery_command += ['--loglevel=DEBUG'] if debug else [] 10 | return Popen(celery_command) 11 | -------------------------------------------------------------------------------- /scripts/utils/_manage_services.py: -------------------------------------------------------------------------------- 1 | import os 2 | from subprocess import Popen 3 | from typing import Optional, Tuple 4 | 5 | from scripts.utils._celery import start_celery_worker 6 | from scripts.utils._postgres import ( 7 | postgres_server_start, 8 | postgres_server_teardown 9 | ) 10 | from scripts.utils._redis import ( 11 | redis_local_url, redis_server_start, redis_server_teardown 12 | ) 13 | from scripts.utils._rabbitmq import ( 14 | local_rabbitmq_uri, init_rabbitmq_app, rabbitmq_server_teardown 15 | ) 16 | 17 | 18 | def setup_services( 19 | postgres_datadir: str = '/usr/local/var/postgres', 20 | celery_worker: bool = False, 21 | debug_celery_worker: bool = False, 22 | ) -> Tuple[Popen, Popen, Optional[Popen]]: 23 | """Start RabbitMQ, Redis and Celery isntances. 24 | 25 | sttgs must be imported here because otherwise it may cause some problems 26 | """ 27 | from app.config import sttgs 28 | 29 | # Start postgres server 30 | postgres_server_start(postgres_datadir) 31 | 32 | # Set env local RabbitMQ URI 33 | os.environ['RABBITMQ_URI'] = local_rabbitmq_uri( 34 | user=sttgs["RABBITMQ_DEFAULT_USER"], 35 | pwd=sttgs["RABBITMQ_DEFAULT_PASS"], 36 | port=sttgs["RABBITMQ_PORT"], 37 | vhost=sttgs["RABBITMQ_DEFAULT_VHOST"] 38 | ) 39 | # Start RabbitMQ 40 | rabbitmq_user = sttgs.get('RABBITMQ_DEFAULT_USER', 'guane') 41 | rabbitmq_pass = sttgs.get('RABBITMQ_DEFAULT_PASS', 'ilovefuelai') 42 | rabbtmq_vhost = sttgs.get('RABBITMQ_DEFAULT_VHOST', 'fuelai') 43 | rabbitmq_server_process, _ = init_rabbitmq_app( # noqa 44 | rabbitmq_user, rabbitmq_pass, rabbtmq_vhost 45 | ) 46 | 47 | # Set env local Redis URI 48 | redis_port = sttgs["REDIS_PORT"] 49 | os.environ['CELERY_BAKCEND_URI'] = redis_local_url(redis_port) 50 | # Start Redis server 51 | redis_server_process = redis_server_start(redis_port) 52 | 53 | if celery_worker: 54 | # Start celery worker 55 | celery_worker_process = start_celery_worker(debug=debug_celery_worker) 56 | else: 57 | celery_worker_process = None 58 | 59 | return rabbitmq_server_process, redis_server_process, celery_worker_process 60 | 61 | 62 | def teardown_services( 63 | rabbitmq_server_process: Popen, 64 | redis_server_process: Popen, 65 | celery_worker_process: Optional[Popen] = None, 66 | postgres_datadir: str = '/usr/local/var/postgres', 67 | ) -> None: 68 | if celery_worker_process is not None: 69 | celery_worker_process.terminate() 70 | rabbitmq_server_teardown(rabbitmq_server_process) 71 | redis_server_teardown(redis_server_process) 72 | postgres_server_teardown(postgres_datadir) 73 | -------------------------------------------------------------------------------- /scripts/utils/_postgres.py: -------------------------------------------------------------------------------- 1 | from subprocess import run, CompletedProcess 2 | 3 | 4 | def postgres_server_start(datadir: str) -> CompletedProcess: 5 | return run(['pg_ctl', '-D', datadir, 'start']) 6 | 7 | 8 | def postgres_server_stop(datadir: str) -> CompletedProcess: 9 | return run(['pg_ctl', '-D', datadir, 'stop']) 10 | 11 | 12 | def postgres_server_teardown(datadir: str) -> CompletedProcess: 13 | return postgres_server_stop(datadir) 14 | -------------------------------------------------------------------------------- /scripts/utils/_rabbitmq.py: -------------------------------------------------------------------------------- 1 | import time 2 | import warnings 3 | from pathlib import Path 4 | from typing import Tuple 5 | from subprocess import Popen, run, CompletedProcess 6 | 7 | 8 | def local_rabbitmq_uri( 9 | user: str, pwd: str, port: str, vhost: str 10 | ) -> str: 11 | return f'amqp://{user}:{pwd}@0.0.0.0:{port}/{vhost}' 12 | 13 | 14 | def init_rabbitmq_app( 15 | rabbitmq_user: str, 16 | rabbitmq_pass: str, 17 | rabbitmq_vhost: str, 18 | max_retries: int = 10, 19 | sleep_time: int = 1 # In seconds 20 | ) -> Tuple[Popen, int]: 21 | """Starts the RabbitMQ server, creates a new user with its credentials, 22 | creates a new virtual host and adds administration priviledges to the 23 | user in the virtual host. 24 | """ 25 | 26 | module_name_tag = f'[{Path(__file__).stem}]' 27 | hidden_pass = "x" * (len(rabbitmq_pass) - 2) + rabbitmq_pass[-2:] 28 | user_with_pass = f'user {rabbitmq_user} with password {hidden_pass}' 29 | 30 | _, _ = rabbitmq_full_start_app() 31 | 32 | # Create user 33 | rabbitmq_user_process = rabbitmq_create_user(rabbitmq_user, rabbitmq_pass) 34 | 35 | if rabbitmq_user_process.returncode == 0: 36 | print(f'{module_name_tag} rabbitmqctl created {user_with_pass} ') 37 | else: 38 | warnings.warn( 39 | f'{module_name_tag} rabbitmqctl couldn\'t create ' 40 | f'{user_with_pass}, probably because the server couldn\'t be ' 41 | 'started appropriately or the user already existed.' 42 | ) 43 | 44 | # Add virtual host 45 | rabbitmq_add_vhost(rabbitmq_vhost) 46 | 47 | # Set user as administrator 48 | rabbitmq_set_user_admin(rabbitmq_user) 49 | 50 | # Set read, write and execute permissions on user 51 | rabbitmq_user_permissions(rabbitmq_vhost, rabbitmq_user) 52 | 53 | # We need to restart the server, this way the newly created user and 54 | # permissions take effect 55 | rabbitmq_server_process, server_ping_statuscode = rabbitmq_restart_server( 56 | max_retries, sleep_time 57 | ) 58 | 59 | return rabbitmq_server_process, server_ping_statuscode 60 | 61 | 62 | def rabbitmq_start_wait_server( 63 | retries: int = 15, sleep_time: int = 1 64 | ) -> Tuple[Popen, int]: 65 | rabbitmq_server_process = Popen(['rabbitmq-server']) 66 | 67 | ping_returncode = 1 68 | 69 | i = 0 70 | while ping_returncode != 0 and i < retries: 71 | time.sleep(sleep_time) 72 | ping_process = run(['rabbitmqctl', 'ping']) 73 | ping_returncode = ping_process.returncode 74 | del ping_process 75 | i += 1 76 | 77 | return rabbitmq_server_process, ping_returncode 78 | 79 | 80 | def rabbitmq_full_start_app( 81 | retries: int = 15, sleep_time: int = 1 82 | ) -> Tuple[Popen, int]: 83 | """Starts both rabbitmq server and application""" 84 | # Start rabbitmq server 85 | rabbitmq_server_process, server_ping_code = rabbitmq_start_wait_server( 86 | retries, sleep_time 87 | ) 88 | # Start rabbitmq application 89 | run(['rabbitmqctl', 'start_app']) 90 | run(['rabbitmqctl', 'await_startup']) 91 | return rabbitmq_server_process, server_ping_code 92 | 93 | 94 | def rabbitmq_create_user( 95 | rabbitmq_user: str, rabbitmq_pass: str 96 | ) -> CompletedProcess: 97 | return run( 98 | ['rabbitmqctl', 'add_user', rabbitmq_user, rabbitmq_pass] 99 | ) 100 | 101 | 102 | def rabbitmq_add_vhost(rabbitmq_vhost: str) -> CompletedProcess: 103 | return run(['rabbitmqctl', 'add_vhost', rabbitmq_vhost]) 104 | 105 | 106 | def rabbitmq_set_user_admin( 107 | rabbitmq_user: str 108 | ) -> CompletedProcess: 109 | # Set user as administrator 110 | run( 111 | ['rabbitmqctl', 'set_user_tags', rabbitmq_user, 'administrator'] 112 | ) 113 | 114 | 115 | def rabbitmq_user_permissions( 116 | rabbitmq_vhost: str, 117 | rabbitmq_user: str, 118 | permissions: Tuple[str, str, str] = ('.*', '.*', '.*') 119 | ): 120 | """Set read, write and execute permissions on user""" 121 | cmd_base = [ 122 | 'rabbitmqctl', 'set_permissions', '-p', rabbitmq_vhost, rabbitmq_user 123 | ] 124 | run(cmd_base + list(permissions)) 125 | 126 | 127 | def rabbitmq_restart_server( 128 | retries: int = 15, sleep_time: int = 1 129 | ) -> Tuple[Popen, int]: 130 | run(['rabbitmqctl', 'shutdown']) 131 | return rabbitmq_start_wait_server(retries, sleep_time) 132 | 133 | 134 | def rabbitmq_reset_and_shut_down_server(): 135 | rabbitmq_start_wait_server() 136 | run(['rabbitmqctl', 'stop_app']) 137 | run(['rabbitmqctl', 'reset']) 138 | run(['rabbitmqctl', 'shutdown']) 139 | 140 | 141 | def rabbitmq_server_teardown(rabbitmq_server_process: Popen): 142 | rabbitmq_server_process.terminate() 143 | rabbitmq_reset_and_shut_down_server() 144 | -------------------------------------------------------------------------------- /scripts/utils/_redis.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from subprocess import Popen, run, CompletedProcess 3 | 4 | 5 | def redis_local_url(port: str) -> str: 6 | return f'redis://0.0.0.0:{port}' 7 | 8 | 9 | def redis_server_start(port: str) -> Popen: 10 | return Popen(['redis-server', '--port', port]) 11 | 12 | 13 | def redis_server_shut_down(): 14 | return run(['redis-cli', 'shutdown']) 15 | 16 | 17 | def redis_server_teardown( 18 | redis_server_process: Popen, 19 | delete_file_names: List[str] = ['erl_crash.dump', 'dump.rdb'] 20 | ) -> CompletedProcess: 21 | redis_shut_down_completed_process = redis_server_shut_down() 22 | redis_server_process.terminate() 23 | run(['rm'] + delete_file_names) 24 | return redis_shut_down_completed_process 25 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = app 3 | version = attr:app.VERSION 4 | author = Juan Esteban Aristizabal-Zuluaga 5 | author_email = jeaz.git@gmail.com 6 | description = RESTful API project 7 | long_description = file: README.md 8 | long_description_content_type = text/markdown 9 | url = https://github.com/jearistiz/guane-intern-fastapi/ 10 | license = MIT 11 | classifiers = 12 | Programming Language :: Python :: 3 13 | Programming Language :: Python :: 3.9 14 | 15 | [options] 16 | package_dir = 17 | = . 18 | packages = find: 19 | python_requires = >=3.8 20 | zip_safe = False 21 | 22 | [options.packages.find] 23 | where = . 24 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | setuptools.setup() 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/fastapi-python-intern/b3f3ed671bec5fff3e45767a5c6d8e72afbcafd6/tests/__init__.py -------------------------------------------------------------------------------- /tests/app/api/routers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/fastapi-python-intern/b3f3ed671bec5fff3e45767a5c6d8e72afbcafd6/tests/app/api/routers/__init__.py -------------------------------------------------------------------------------- /tests/app/api/routers/test_dogs.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | from fastapi.testclient import TestClient 3 | 4 | from app.config import sttgs 5 | from mock_data.db_test_data import dogs_mock_dicts, adopted_dogs_dicts 6 | from tests.utils.handle_db_test import HandleDBTest 7 | 8 | 9 | class TestDogsRouter(HandleDBTest): 10 | 11 | dogs_api_prefix = sttgs.get('API_PREFIX') + sttgs.get('DOGS_API_PREFIX') 12 | 13 | def dogs_name_route(self, name): 14 | return self.dogs_api_prefix + '/' + name 15 | 16 | def assert_dogs_data(self, *, reference: dict, compare: dict): 17 | assert compare['name'] == reference['name'] 18 | assert 'create_date' in compare 19 | assert 'id' in compare 20 | assert 'picture' in compare 21 | assert 'is_adopted' in compare 22 | assert 'id_user' in compare 23 | 24 | def test_get_dogs(self, app_client: TestClient) -> None: 25 | response = app_client.get(self.dogs_api_prefix) 26 | assert response.status_code == 200 27 | content = response.json() 28 | assert isinstance(content['dogs'], list) 29 | dogs = content['dogs'] 30 | dogs_names = [ref_dog['name'] for ref_dog in dogs_mock_dicts] 31 | for dog in dogs: 32 | assert dog['name'] in dogs_names 33 | 34 | def test_get_dogs_is_adopted(self, app_client: TestClient) -> None: 35 | get_dogs_is_adopted_route = self.dogs_api_prefix + '/is_adopted' 36 | response = app_client.get(get_dogs_is_adopted_route) 37 | assert response.status_code == 200 38 | content = response.json() 39 | assert isinstance(content['adopted_dogs'], list) 40 | dogs = content['adopted_dogs'] 41 | adopted_dogs_names = [ 42 | ref_dog['name'] for ref_dog in adopted_dogs_dicts 43 | ] 44 | for dog in dogs: 45 | assert dog['is_adopted'] is True 46 | assert dog['name'] in adopted_dogs_names 47 | 48 | def test_get_dogs_name(self, app_client: TestClient) -> None: 49 | data = dogs_mock_dicts[0] 50 | get_dogs_name_route = self.dogs_name_route(data.get('name')) 51 | response = app_client.get(get_dogs_name_route, json=data) 52 | assert response.status_code == 200 53 | content = response.json() 54 | self.assert_dogs_data(reference=data, compare=content) 55 | 56 | def test_post_dogs_name( 57 | self, app_client: TestClient, superuser_token_headers: Dict[str, str] 58 | ) -> None: 59 | data = dogs_mock_dicts[0].copy() 60 | data.update({'name': 'Juan'}) 61 | data['picture'] = None 62 | post_dogs_name_route = self.dogs_name_route(data.get('name')) 63 | response = app_client.post( 64 | post_dogs_name_route, json=data, headers=superuser_token_headers 65 | ) 66 | assert response.status_code == 201 67 | content = response.json() 68 | self.assert_dogs_data(reference=data, compare=content) 69 | 70 | def test_put_dogs_name( 71 | self, app_client: TestClient, superuser_token_headers: Dict[str, str] 72 | ) -> None: 73 | data = dogs_mock_dicts[0].copy() 74 | old_name = data['name'] 75 | data.update({'name': 'Juan'}) 76 | put_dogs_name_route = self.dogs_name_route(old_name) 77 | response = app_client.put( 78 | put_dogs_name_route, json=data, headers=superuser_token_headers 79 | ) 80 | assert response.status_code == 200 81 | content = response.json() 82 | self.assert_dogs_data(reference=data, compare=content) 83 | 84 | def test_delete_dogs_name( 85 | self, app_client: TestClient, superuser_token_headers: Dict[str, str] 86 | ) -> None: 87 | data = dogs_mock_dicts[0] 88 | get_dogs_name_route = self.dogs_name_route(data.get('name')) 89 | response = app_client.delete( 90 | get_dogs_name_route, json=data, headers=superuser_token_headers 91 | ) 92 | assert response.status_code == 200 93 | content = response.json() 94 | self.assert_dogs_data(reference=data, compare=content) 95 | -------------------------------------------------------------------------------- /tests/app/api/routers/test_security.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from fastapi.testclient import TestClient 4 | 5 | from app.config import sttgs 6 | 7 | 8 | def test_post_login_for_access_token(app_client: TestClient) -> Dict[str, str]: 9 | login_data = { 10 | "username": sttgs['FIRST_SUPERUSER'], 11 | "password": sttgs['FIRST_SUPERUSER_PASSWORD'], 12 | } 13 | r = app_client.post(f"{sttgs['TOKEN_URI']}", data=login_data) 14 | tokens = r.json() 15 | assert tokens['access_token'] 16 | assert tokens['token_type'] 17 | -------------------------------------------------------------------------------- /tests/app/api/routers/test_tasks.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from fastapi.testclient import TestClient 4 | 5 | from tests.utils.uri import task_uri 6 | 7 | 8 | def assert_post_uri_task( 9 | client: TestClient, 10 | superuser_token_headers: Dict[str, str], 11 | specific_endpoint: str, 12 | task_complexity: int, 13 | not_async: bool = False 14 | ): 15 | response = client.post( 16 | task_uri(specific_endpoint, task_complexity), 17 | headers=superuser_token_headers 18 | ) 19 | assert response.status_code == 201 20 | content = response.json() 21 | assert content['task_complexity'] == task_complexity 22 | assert content["status"] 23 | if not_async: 24 | assert content['server_message'] 25 | else: 26 | assert content['server_message'] is None 27 | assert content['success'] is True 28 | 29 | 30 | def test_celery_task( 31 | app_client: TestClient, 32 | superuser_token_headers: Dict[str, str] 33 | ): 34 | assert_post_uri_task( 35 | app_client, superuser_token_headers, 36 | specific_endpoint='/celery_task', 37 | task_complexity=0, 38 | ) 39 | 40 | 41 | def test_celery_task_not_async( 42 | app_client: TestClient, 43 | superuser_token_headers: Dict[str, str] 44 | ): 45 | assert_post_uri_task( 46 | app_client, superuser_token_headers, 47 | task_complexity=0, 48 | specific_endpoint='/celery_task_not_async', 49 | not_async=True, 50 | ) 51 | -------------------------------------------------------------------------------- /tests/app/api/routers/test_upload_file.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from fastapi.testclient import TestClient 4 | 5 | from app.config import sttgs 6 | 7 | 8 | upload_file_uri = ( 9 | sttgs.get('API_PREFIX') + sttgs.get('UPLOAD_API_PREFIX') + '/file-to-guane' 10 | ) 11 | 12 | 13 | def test_post_file_to_guane( 14 | app_client: TestClient, 15 | superuser_token_headers: Dict[str, str] 16 | ): 17 | response = app_client.post( 18 | upload_file_uri, 19 | headers=superuser_token_headers 20 | ) 21 | assert response.status_code == 201 22 | content = response.json() 23 | assert content['success'] is True 24 | assert content['remote_server_status_code'] == 201 25 | assert 'filename' in content['remote_server_response'] 26 | -------------------------------------------------------------------------------- /tests/app/api/routers/test_users.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from fastapi.testclient import TestClient 4 | 5 | from app.config import sttgs 6 | from mock_data.db_test_data import users_mock_dicts 7 | from tests.utils.handle_db_test import HandleDBTest 8 | 9 | 10 | class TestUsersRouter(HandleDBTest): 11 | 12 | users_api_prefix = sttgs.get('API_PREFIX') + sttgs.get('USERS_API_PREFIX') 13 | 14 | def users_name_route(self, name): 15 | return self.users_api_prefix + '/' + name 16 | 17 | def assert_users_data(self, *, reference: dict, compare: dict): 18 | assert compare['name'] == reference['name'] 19 | assert compare['email'] == reference['email'] 20 | assert 'create_date' in compare 21 | assert 'id' in compare 22 | 23 | def test_get_users(self, app_client: TestClient): 24 | response = app_client.get(self.users_api_prefix) 25 | assert response.status_code == 200 26 | content = response.json() 27 | assert isinstance(content['users'], list) 28 | users = content['users'] 29 | user_names = [ref_user['name'] for ref_user in users_mock_dicts] 30 | for user in users: 31 | assert user['name'] in user_names 32 | 33 | def test_get_users_name(self, app_client: TestClient): 34 | data = users_mock_dicts[0] 35 | get_users_name_route = self.users_name_route(data.get('name')) 36 | response = app_client.get(get_users_name_route, json=data) 37 | assert response.status_code == 200 38 | content = response.json() 39 | self.assert_users_data(reference=data, compare=content) 40 | 41 | def test_post_users_name( 42 | self, app_client: TestClient, superuser_token_headers: Dict[str, str] 43 | ) -> None: 44 | data = users_mock_dicts[0].copy() 45 | data.update({'name': 'Juan'}) 46 | post_users_name_route = self.users_name_route(data.get('name')) 47 | response = app_client.post( 48 | post_users_name_route, json=data, headers=superuser_token_headers 49 | ) 50 | assert response.status_code == 201 51 | content = response.json() 52 | self.assert_users_data(reference=data, compare=content) 53 | 54 | def test_put_users_name( 55 | self, app_client: TestClient, superuser_token_headers: Dict[str, str] 56 | ) -> None: 57 | data = users_mock_dicts[0].copy() 58 | old_name = data['name'] 59 | data.update({'name': 'Juan'}) 60 | post_users_name_route = self.users_name_route(old_name) 61 | response = app_client.put( 62 | post_users_name_route, json=data, headers=superuser_token_headers 63 | ) 64 | assert response.status_code == 200 65 | content = response.json() 66 | self.assert_users_data(reference=data, compare=content) 67 | 68 | def test_delete_users_name( 69 | self, app_client: TestClient, superuser_token_headers: Dict[str, str] 70 | ) -> None: 71 | data = users_mock_dicts[0] 72 | get_users_name_route = self.users_name_route(data.get('name')) 73 | response = app_client.delete( 74 | get_users_name_route, json=data, headers=superuser_token_headers 75 | ) 76 | assert response.status_code == 200 77 | content = response.json() 78 | self.assert_users_data(reference=data, compare=content) 79 | -------------------------------------------------------------------------------- /tests/app/crud/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/fastapi-python-intern/b3f3ed671bec5fff3e45767a5c6d8e72afbcafd6/tests/app/crud/__init__.py -------------------------------------------------------------------------------- /tests/app/crud/test_dog_crud.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Session 2 | 3 | from app import crud 4 | from mock_data.db_test_data import adopted_dogs_dicts 5 | from tests.utils.handle_db_test import HandleDBTest 6 | from tests.utils.parse_dict import update_dict_fmt_item 7 | 8 | 9 | class TestDogCrud(HandleDBTest): 10 | def test_get_adopter(self, db: Session): 11 | adopted_dogs_out = crud.dog.get_adopted(db) 12 | for adopted_dog_out in adopted_dogs_out: 13 | adopted_dog_dict = adopted_dog_out._asdict() 14 | adopted_dog_dict.pop('id') 15 | update_dict_fmt_item(adopted_dog_dict, 'create_date', str) 16 | assert adopted_dog_dict in adopted_dogs_dicts 17 | -------------------------------------------------------------------------------- /tests/app/crud/test_user_crud.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Session 2 | 3 | from app import crud 4 | from app.models import User 5 | from app.schemas import UserUpdate, UserCreate 6 | from mock_data.db_test_data import users_mock_dicts, users_mock 7 | from tests.utils.handle_db_test import HandleDBTest 8 | from tests.utils.parse_dict import update_dict_fmt_item 9 | 10 | 11 | class TestUserCrud(HandleDBTest): 12 | 13 | def test_get(self, db: Session): 14 | user_out = crud.user.get(db, id=1) 15 | user_compare = user_out._asdict() 16 | user_compare.pop('id') 17 | update_dict_fmt_item(user_compare, 'create_date', str) 18 | assert user_compare in users_mock_dicts 19 | 20 | def test_get_by_name(self, db: Session): 21 | name = users_mock_dicts[0]['name'] 22 | user_out = crud.user.get_by_name(db, name_in=name) 23 | assert isinstance(user_out, User), f'{name} is supposed to be in db' 24 | user_compare = user_out._asdict() 25 | user_compare.pop('id') 26 | update_dict_fmt_item(user_compare, 'create_date', str) 27 | assert user_compare in users_mock_dicts 28 | 29 | def test_get_multi(self, db: Session): 30 | users_out = crud.user.get_multi(db) 31 | for user_out in users_out: 32 | user_compare = user_out._asdict() 33 | user_compare.pop('id') 34 | update_dict_fmt_item(user_compare, 'create_date', str) 35 | assert user_compare in users_mock_dicts 36 | 37 | def test_create(self, db: Session): 38 | user = users_mock[0] 39 | created_obj = crud.user.create(db, obj_in=user) 40 | created_obj_dict = created_obj._asdict() 41 | created_obj_dict.pop('id') 42 | assert created_obj_dict == user.dict(exclude_unset=True) 43 | crud.user.remove(db, id=created_obj.id) 44 | 45 | def test_update(self, db: Session): 46 | user_id = 1 47 | updated_last_name = 'Analytics' 48 | obj = crud.user.get(db, id=user_id) 49 | updated_obj_info = obj._asdict() 50 | updated_obj_info.update({'last_name': updated_last_name}) 51 | obj_in = UserUpdate(**updated_obj_info) 52 | updated_obj = crud.user.update(db, db_obj=obj, obj_in=obj_in) 53 | assert updated_obj.id == user_id 54 | assert updated_obj.last_name == updated_last_name 55 | 56 | def test_update_by_name(self, db: Session): 57 | user_name = users_mock_dicts[0]['name'] 58 | user_obj = crud.user.get_by_name(db, name_in=user_name) 59 | updated_last_name = 'Analytics' 60 | updated_obj_info = user_obj._asdict() 61 | updated_obj_info.update({'last_name': updated_last_name}) 62 | obj_in = UserUpdate(**updated_obj_info) 63 | updated_obj = crud.user.update_by_name( 64 | db, name_in_db=user_name, obj_in=obj_in 65 | ) 66 | assert updated_obj.id == user_obj.id 67 | assert updated_obj.last_name == updated_last_name 68 | 69 | def test_remove(self, db: Session): 70 | user_id = 3 71 | deleted_user = crud.user.remove(db, id=user_id) 72 | assert deleted_user.id == user_id 73 | crud.user.create(db, obj_in=UserCreate(**deleted_user._asdict())) 74 | 75 | def test_remove_one_by_name(self, db: Session): 76 | user_name = users_mock_dicts[3]['name'] 77 | deleted_user = crud.user.remove_one_by_name(db, name=user_name) 78 | assert deleted_user.name == user_name 79 | crud.user.create(db, obj_in=UserCreate(**deleted_user._asdict())) 80 | -------------------------------------------------------------------------------- /tests/app/test_app.py: -------------------------------------------------------------------------------- 1 | from app import VERSION 2 | 3 | 4 | def test_version(): 5 | assert len(VERSION.split('.')) == 3 6 | -------------------------------------------------------------------------------- /tests/app/test_conf.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Mapping 2 | 3 | from app import config 4 | 5 | 6 | def test_sttgs(): 7 | assert isinstance(config.sttgs, Mapping) 8 | -------------------------------------------------------------------------------- /tests/app/test_main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | from app import main 4 | 5 | 6 | def test_app(): 7 | assert isinstance(main.app, FastAPI) 8 | -------------------------------------------------------------------------------- /tests/app/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/fastapi-python-intern/b3f3ed671bec5fff3e45767a5c6d8e72afbcafd6/tests/app/utils/__init__.py -------------------------------------------------------------------------------- /tests/app/utils/test_http_requests.py: -------------------------------------------------------------------------------- 1 | from app.config import sttgs 2 | from app.utils.http_request import post_to_uri 3 | 4 | 5 | def test_post_to_uri(): 6 | task_complexity = 0 7 | task_query_url = ( 8 | sttgs.get('GUANE_WORKER_URI') + f'?task_complexity={task_complexity}' 9 | ) 10 | response = post_to_uri( 11 | task_query_url, 12 | message={'task_complexity': task_complexity} 13 | ) 14 | assert response.status_code == 201 15 | assert response.json()['status'] 16 | -------------------------------------------------------------------------------- /tests/app/worker/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/fastapi-python-intern/b3f3ed671bec5fff3e45767a5c6d8e72afbcafd6/tests/app/worker/__init__.py -------------------------------------------------------------------------------- /tests/app/worker/test_celery_tasks.py: -------------------------------------------------------------------------------- 1 | from app.worker.tasks import post_to_uri_task 2 | 3 | 4 | def test_task_post_to_uri(): 5 | 6 | task_data = post_to_uri_task() 7 | 8 | assert task_data['status_code'] == 201 9 | assert task_data['data'] 10 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from typing import Generator, Dict 2 | 3 | import pytest 4 | from fastapi.testclient import TestClient 5 | 6 | from app.main import app 7 | from app.api.deps import get_db 8 | from tests.mock.db_session import ( 9 | TestSessionLocal, 10 | testing_get_db, 11 | setup_test_db, 12 | teardown_test_db, 13 | ) 14 | from tests.utils.security import get_superuser_token_headers 15 | 16 | 17 | # Setup tests 18 | def pytest_sessionstart(session: pytest.Session): 19 | setup_test_db() 20 | 21 | 22 | # Delete all tables in test DB 23 | def pytest_sessionfinish(session: pytest.Session): 24 | teardown_test_db() 25 | 26 | 27 | @pytest.fixture(scope="function") 28 | def db() -> Generator: 29 | db = TestSessionLocal() 30 | try: 31 | yield db 32 | finally: 33 | db.close() 34 | 35 | 36 | @pytest.fixture(scope="function") 37 | def app_client(db) -> Generator: 38 | app.dependency_overrides[get_db] = testing_get_db 39 | test_app = TestClient(app) 40 | with test_app as c: 41 | yield c 42 | 43 | 44 | @pytest.fixture(scope="function") 45 | def superuser_token_headers(app_client: TestClient) -> Dict[str, str]: 46 | return get_superuser_token_headers(app_client) 47 | -------------------------------------------------------------------------------- /tests/mock/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/fastapi-python-intern/b3f3ed671bec5fff3e45767a5c6d8e72afbcafd6/tests/mock/__init__.py -------------------------------------------------------------------------------- /tests/mock/db_session.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | from sqlalchemy import create_engine 3 | from sqlalchemy.orm import sessionmaker 4 | 5 | from app.config import sttgs 6 | from app.db.db_manager import create_all_tables, drop_all_tables 7 | from app.db.utils.populate_tables import populate_tables_mock_data 8 | from mock_data.db_test_data import dogs_mock, users_mock 9 | 10 | 11 | test_engine = create_engine( 12 | sttgs.get('POSTGRES_TESTS_URI'), 13 | pool_pre_ping=True, 14 | echo=True 15 | ) 16 | 17 | 18 | TestSessionLocal = sessionmaker( 19 | autocommit=False, 20 | autoflush=False, 21 | bind=test_engine 22 | ) 23 | 24 | 25 | def populate_test_tables() -> None: 26 | # Populate tables with test db data 27 | populate_tables_mock_data( 28 | populate=True, 29 | Session=TestSessionLocal, 30 | dogs_in=dogs_mock, 31 | users_in=users_mock 32 | ) 33 | 34 | 35 | def setup_test_db() -> None: 36 | drop_all_tables(engine=test_engine, drop=True) 37 | create_all_tables(engine=test_engine) 38 | populate_test_tables() 39 | 40 | 41 | def teardown_test_db() -> None: 42 | drop_all_tables(engine=test_engine, drop=True) 43 | 44 | 45 | def testing_get_db() -> Generator: 46 | """For some reason, app.dependency_overrides does not accept pytest 47 | fixtures as overrider, so this function is needed although it is exactlythe 48 | same as db 49 | """ 50 | db = TestSessionLocal() 51 | try: 52 | yield db 53 | finally: 54 | db.close() 55 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/fastapi-python-intern/b3f3ed671bec5fff3e45767a5c6d8e72afbcafd6/tests/utils/__init__.py -------------------------------------------------------------------------------- /tests/utils/handle_db_test.py: -------------------------------------------------------------------------------- 1 | from tests.mock.db_session import ( # noqa 2 | setup_test_db, 3 | teardown_test_db, 4 | ) 5 | 6 | 7 | class HandleDBTest: 8 | """This Class assures that all tests within a subclass are done in 9 | the same database-circumstances 10 | """ 11 | def setup_method(self): 12 | # populate_test_tables 13 | setup_test_db() 14 | 15 | def teardown_method(self): 16 | teardown_test_db() 17 | 18 | @classmethod 19 | def teardown_class(cls): 20 | setup_test_db() 21 | -------------------------------------------------------------------------------- /tests/utils/parse_dict.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable 2 | 3 | 4 | def update_dict_fmt_item(obj: dict, key: Any, format: Callable): 5 | obj.update({key: format(obj[key])}) 6 | -------------------------------------------------------------------------------- /tests/utils/security.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from fastapi.testclient import TestClient 4 | 5 | from app.config import sttgs 6 | 7 | 8 | def get_superuser_token_headers(app_client: TestClient) -> Dict[str, str]: 9 | login_data = { 10 | "username": sttgs['FIRST_SUPERUSER'], 11 | "password": sttgs['FIRST_SUPERUSER_PASSWORD'], 12 | } 13 | r = app_client.post(f"{sttgs['TOKEN_URI']}", data=login_data) 14 | tokens = r.json() 15 | a_token = tokens["access_token"] 16 | headers = {"Authorization": f"Bearer {a_token}"} 17 | return headers 18 | -------------------------------------------------------------------------------- /tests/utils/uri.py: -------------------------------------------------------------------------------- 1 | from app.config import sttgs 2 | 3 | 4 | def task_uri(specific_endpoint: str, task_complexity: int = 0) -> str: 5 | return ( 6 | sttgs.get('API_PREFIX') 7 | + sttgs.get('CELERY_TASKS_PREFIX') 8 | + specific_endpoint 9 | + '?task_complexity=' 10 | + str(task_complexity) 11 | ) 12 | --------------------------------------------------------------------------------