├── .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 |
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 |
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 |
--------------------------------------------------------------------------------