44 Commits
0.4.0 ... 0.5.0

Author SHA1 Message Date
Sebastián Ramírez
a25f2c14e4 🔖 Release version 0.5.0 2020-04-19 08:52:11 +02:00
Sebastián Ramírez
122d983415 📝 Update release notes 2020-04-19 08:51:36 +02:00
Sebastián Ramírez
d08d9314ce 📌 Make the public Traefik network a fixed default (#150)
to simplify development
2020-04-19 08:50:00 +02:00
Sebastián Ramírez
ff55b778ba 📝 Update release notes 2020-04-19 07:57:18 +02:00
Ruslan Samoylov
8812ca6635 ⬆️ Upgrade to Postgres 12 (#148) 2020-04-19 07:56:05 +02:00
Sebastián Ramírez
2e8da3a590 📝 Update release notes 2020-04-18 23:30:01 +02:00
Sebastián Ramírez
00297f974f 🙈 Update gitignore with Poetry 2020-04-18 23:29:44 +02:00
Ruslan Samoylov
c8bcc0ba0a Use Poetry for package management (#144)
* use poetry insted of Pipfile

* fix python black version

* set prepare.sh as executable

* revert postgres 11

* use multi-build stage in docker

* fix poetry path

* 🔥 Remove uneeded changes

* 🔧 Move and update Poetry file

* 🙈 Update gitignore

* 🐳 Update Dockerfiles to use Poetry

* 🐳 Update Dockerfiles with Poetry

* 🔧 Add SERVER_NAME required by Celery worker

* 🐳 Update Poetry install to avoid env conflicts

*  Add Pytest to Poetry dependencies

Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com>
2020-04-18 23:27:48 +02:00
Sebastián Ramírez
0a194b3b00 📝 Update README, sync with FastAPI docs 2020-04-18 17:48:48 +02:00
Sebastián Ramírez
94b2474438 📝 Update release notes 2020-04-18 10:16:16 +02:00
Sebastián Ramírez
af4e0cfe10 🐛 Fix Windows line endings for shell scripts after generation (#149) 2020-04-18 10:15:00 +02:00
Sebastián Ramírez
001dbda103 📝 Update release notes 2020-04-17 16:35:15 +02:00
Brendon Smith
34f6f9ae54 ⬆️ Upgrade to Vue CLI 4 (#120)
* Upgrade to Vue CLI 4

https://cli.vuejs.org/migrating-from-v3

* 🔥 Remove package-lock.json that varies by system

Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com>
2020-04-17 16:33:51 +02:00
Sebastián Ramírez
0c8e682a90 📝 Update release notes 2020-04-17 14:56:05 +02:00
Matthew Shu
67b384f308 🔥 Remove duplicate 'login' tag (#135)
Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com>
2020-04-17 14:54:47 +02:00
Sebastián Ramírez
4bd791c11d 📝 Update release notes 2020-04-17 14:34:37 +02:00
Radek Lonka
697b4da6b0 🐛 Fix welcome message to show email if full name doe not exists (#129) 2020-04-17 14:31:30 +02:00
Sebastián Ramírez
854cc709d1 📝 Update release notes 2020-04-17 14:22:02 +02:00
Brendon Smith
21c4d11659 🎨 Bring Python code into compliance with Black and Flake8 (#121)
* Ignore Flake8 unused import error F401

https://flake8.readthedocs.io/en/latest/user/error-codes.html

The apparently unused imports may be needed for SQLAlchemy.

As the code comment says:

make sure all SQL Alchemy models are imported before initializing DB
otherwise, SQL Alchemy might fail to initialize properly relationships

See GitHub 28 and 29

* Ignore Flake8 unused variable error F841

https://flake8.readthedocs.io/en/latest/user/error-codes.html

The apparently unused variables may be needed for tests.

* Bring line length into compliance with Black

Should be 88 characters.

* Format alembic code with Black
2020-04-17 14:20:48 +02:00
Sebastián Ramírez
bcee2427b9 📝 Update release notes 2020-04-17 09:41:34 +02:00
Albert Iribarne
8a2252f654 ♻️ Simplify DB base class declaration (#117)
* Simplify DB base class declaration

* ♻️ Remove object inheritance

Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com>
2020-04-17 09:39:25 +02:00
Sebastián Ramírez
8ff61e813e 📝 Update release notes 2020-04-17 09:21:32 +02:00
Mocsár Kálmán
fb874fea35 Update CRUD utils for users handling password hashing (#106)
* Add some information how to run backand test for local backand development

* Bug fixes in backend app

* 🎨 Update format

*  Use random_email for test_update_user

Co-authored-by: Mocsar Kalman <mocsar.kalman@gravityrd.com>
Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com>
2020-04-17 09:20:00 +02:00
Sebastián Ramírez
2b9ed9333a 📝 Update release notes 2020-04-17 08:07:39 +02:00
gcharbon
45510b4f80 ♻️ Use . instead of source in build-push.sh (#98) 2020-04-17 08:04:42 +02:00
Sebastián Ramírez
5a79f4e427 📝 Update release notes 2020-04-17 08:01:35 +02:00
Stephen Brown II
79631c7619 Use Pydantic BaseSettings for config settings (#87)
* Use Pydantic BaseSettings for config settings

* Update fastapi dep to >=0.47.0 and email_validator to email-validator

* Fix deprecation warning for Pydantic >=1.0

* Properly support old-format comma separated strings for BACKEND_CORS_ORIGINS

Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com>
2020-04-17 07:56:10 +02:00
Sebastián Ramírez
cd875e5bef 👷 Add GitHub action issue-manager 2020-04-12 13:10:38 +02:00
Sebastián Ramírez
1a92a0a6f1 🔥 Remove package-lock.json 2020-04-06 17:28:53 +02:00
Sebastián Ramírez
2eb5b030bd 📝 Update release notes 2020-04-06 12:39:26 +02:00
Sebastián Ramírez
7c2c2276d9 ♻️ Simplify Traefik labels in services (#139) 2020-04-06 12:38:28 +02:00
Sebastián Ramírez
baf584a6cd 📝 Update release notes 2020-04-06 11:37:52 +02:00
Teomor Szczurek
970a182ec8 Add email validation (#40)
* modify tests

*  Add email-validator to Dockerfiles

* ♻️ Update random email generation

* ♻️ Re-apply email validation after rebase

Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com>
2020-04-06 11:36:29 +02:00
Sebastián Ramírez
1d8678235d 📝 Update release notes 2020-02-07 23:17:38 +01:00
Ashton Shears
71f430616c ✏️ Fix typo (#83) 2020-02-07 21:46:09 +01:00
Abhishek S
43e508239c :✏️ Fix typo (#80) 2020-02-07 21:44:37 +01:00
Cristobal Aguirre
dc712ac4ec 🐛 Fix typo in read_item GET view (#74) 2020-02-07 21:28:45 +01:00
Sebastián Ramírez
141f6cdb6e 📝 Update release notes 2020-02-07 21:17:28 +01:00
Daniel Butler
fc403c9bc1 ✏️ Correct grammar (#70) 2020-02-07 21:15:10 +01:00
David Montague
4b93dc709f 🐛 Fix docker configuration for flower (#37) 2020-02-07 21:04:02 +01:00
Sebastián Ramírez
2db416d3c1 📝 Update release notes 2020-01-19 22:49:17 +01:00
Manu
ab46165387 Add base class to simplify CRUD (#23) 2020-01-19 22:40:50 +01:00
Sebastián Ramírez
1c975c7f2d 📝 Update release notes 2020-01-19 13:27:07 +01:00
Manu
248ea56c6e Add normal-user fixture for testing (#20) 2020-01-19 13:25:17 +01:00
72 changed files with 819 additions and 14524 deletions

19
.github/workflows/main.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
on:
schedule:
- cron: "0 0 * * *"
jobs:
issue-manager:
runs-on: ubuntu-latest
steps:
- uses: tiangolo/issue-manager@master
with:
token: ${{ secrets.GITHUB_TOKEN }}
config: >
{
"answered": {
"users": ["tiangolo"],
"delay": 864000,
"message": "Assuming the original issue was solved, it will be automatically closed now. But feel free to add more comments or create new issues."
}
}

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.vscode .vscode
testing-project testing-project
.mypy_cache .mypy_cache
poetry.lock

View File

@@ -27,23 +27,23 @@ Generate a backend and frontend stack using Python, including interactive API do
* Full **Docker** integration (Docker based). * Full **Docker** integration (Docker based).
* Docker Swarm Mode deployment. * Docker Swarm Mode deployment.
* **Docker Compose** integration and optimization for local development * **Docker Compose** integration and optimization for local development.
* **Production ready** Python web server using Uvicorn and Gunicorn. * **Production ready** Python web server using Uvicorn and Gunicorn.
* Python **[FastAPI](https://github.com/tiangolo/fastapi)** backend: * Python <a href="https://github.com/tiangolo/fastapi" class="external-link" target="_blank">**FastAPI**</a> backend:
* **Fast**: Very high performance, on par with **NodeJS** and **Go** (thanks to Starlette and Pydantic). * **Fast**: Very high performance, on par with **NodeJS** and **Go** (thanks to Starlette and Pydantic).
* **Intuitive**: Great editor support. <abbr title="also known as auto-complete, autocompletion, IntelliSense">Completion</abbr> everywhere. Less time debugging. * **Intuitive**: Great editor support. <abbr title="also known as auto-complete, autocompletion, IntelliSense">Completion</abbr> everywhere. Less time debugging.
* **Easy**: Designed to be easy to use and learn. Less time reading docs. * **Easy**: Designed to be easy to use and learn. Less time reading docs.
* **Short**: Minimize code duplication. Multiple features from each parameter declaration. * **Short**: Minimize code duplication. Multiple features from each parameter declaration.
* **Robust**: Get production-ready code. With automatic interactive documentation. * **Robust**: Get production-ready code. With automatic interactive documentation.
* **Standards-based**: Based on (and fully compatible with) the open standards for APIs: <a href="https://github.com/OAI/OpenAPI-Specification" target="_blank">OpenAPI</a> and <a href="http://json-schema.org/" target="_blank">JSON Schema</a>. * **Standards-based**: Based on (and fully compatible with) the open standards for APIs: <a href="https://github.com/OAI/OpenAPI-Specification" class="external-link" target="_blank">OpenAPI</a> and <a href="http://json-schema.org/" class="external-link" target="_blank">JSON Schema</a>.
* [**Many other features**](https://github.com/tiangolo/fastapi) including automatic validation, serialization, interactive documentation, authentication with OAuth2 JWT tokens, etc. * <a href="https://fastapi.tiangolo.com/features/" class="external-link" target="_blank">**Many other features**</a> including automatic validation, serialization, interactive documentation, authentication with OAuth2 JWT tokens, etc.
* **Secure password** hashing by default. * **Secure password** hashing by default.
* **JWT token** authentication. * **JWT token** authentication.
* **SQLAlchemy** models (independent of Flask extensions, so they can be used with Celery workers directly). * **SQLAlchemy** models (independent of Flask extensions, so they can be used with Celery workers directly).
* Basic starting models for users (modify and remove as you need). * Basic starting models for users (modify and remove as you need).
* **Alembic** migrations. * **Alembic** migrations.
* **CORS** (Cross Origin Resource Sharing). * **CORS** (Cross Origin Resource Sharing).
* **Celery** worker that can import and use models and code from the rest of the backend selectively (you don't have to install the complete app in each worker). * **Celery** worker that can import and use models and code from the rest of the backend selectively.
* REST backend tests based on **Pytest**, integrated with Docker, so you can test the full API interaction, independent on the database. As it runs in Docker, it can build a new data store from scratch each time (so you can use ElasticSearch, MongoDB, CouchDB, or whatever you want, and just test that the API works). * REST backend tests based on **Pytest**, integrated with Docker, so you can test the full API interaction, independent on the database. As it runs in Docker, it can build a new data store from scratch each time (so you can use ElasticSearch, MongoDB, CouchDB, or whatever you want, and just test that the API works).
* Easy Python integration with **Jupyter Kernels** for remote or in-Docker development with extensions like Atom Hydrogen or Visual Studio Code Jupyter. * Easy Python integration with **Jupyter Kernels** for remote or in-Docker development with extensions like Atom Hydrogen or Visual Studio Code Jupyter.
* **Vue** frontend: * **Vue** frontend:
@@ -69,7 +69,7 @@ Generate a backend and frontend stack using Python, including interactive API do
## How to use it ## How to use it
Go to the directoy where you want to create your project and run: Go to the directory where you want to create your project and run:
```bash ```bash
pip install cookiecutter pip install cookiecutter
@@ -105,7 +105,7 @@ The input variables, with their default values (some auto generated) are:
* `secret_key`: Backend server secret key. Use the method above to generate it. * `secret_key`: Backend server secret key. Use the method above to generate it.
* `first_superuser`: The first superuser generated, with it you will be able to create more users, etc. By default, based on the domain. * `first_superuser`: The first superuser generated, with it you will be able to create more users, etc. By default, based on the domain.
* `first_superuser_password`: First superuser password. Use the method above to generate it. * `first_superuser_password`: First superuser password. Use the method above to generate it.
* `backend_cors_origins`: Origins (domains, more or less) that are enabled for CORS (Cross Origin Resource Sharing). This allows a frontend in one domain (e.g. `https://dashboard.example.com`) to communicate with this backend, that could be living in another domain (e.g. `https://api.example.com`). It can also be used to allow your local frontend (with a custom `hosts` domain mapping, as described in the project's `README.md`) that could be living in `http://dev.example.com:8080` to cummunicate with the backend at `https://stag.example.com`. Notice the `http` vs `https` and the `dev.` prefix for local development vs the "staging" `stag.` prefix. By default, it includes origins for production, staging and development, with ports commonly used during local development by several popular frontend frameworks (Vue with `:8080`, React, Angular). * `backend_cors_origins`: Origins (domains, more or less) that are enabled for CORS (Cross Origin Resource Sharing). This allows a frontend in one domain (e.g. `https://dashboard.example.com`) to communicate with this backend, that could be living in another domain (e.g. `https://api.example.com`). It can also be used to allow your local frontend (with a custom `hosts` domain mapping, as described in the project's `README.md`) that could be living in `http://dev.example.com:8080` to communicate with the backend at `https://stag.example.com`. Notice the `http` vs `https` and the `dev.` prefix for local development vs the "staging" `stag.` prefix. By default, it includes origins for production, staging and development, with ports commonly used during local development by several popular frontend frameworks (Vue with `:8080`, React, Angular).
* `smtp_port`: Port to use to send emails via SMTP. By default `587`. * `smtp_port`: Port to use to send emails via SMTP. By default `587`.
* `smtp_host`: Host to use to send emails, it would be given by your email provider, like Mailgun, Sparkpost, etc. * `smtp_host`: Host to use to send emails, it would be given by your email provider, like Mailgun, Sparkpost, etc.
* `smtp_user`: The user to use in the SMTP connection. The value will be given by your email provider. * `smtp_user`: The user to use in the SMTP connection. The value will be given by your email provider.
@@ -118,7 +118,6 @@ The input variables, with their default values (some auto generated) are:
* `traefik_constraint_tag`: The tag to be used by the internal Traefik load balancer (for example, to divide requests between backend and frontend) for production. Used to separate this stack from any other stack you might have. This should identify each stack in each environment (production, staging, etc). * `traefik_constraint_tag`: The tag to be used by the internal Traefik load balancer (for example, to divide requests between backend and frontend) for production. Used to separate this stack from any other stack you might have. This should identify each stack in each environment (production, staging, etc).
* `traefik_constraint_tag_staging`: The Traefik tag to be used while on staging. * `traefik_constraint_tag_staging`: The Traefik tag to be used while on staging.
* `traefik_public_network`: This assumes you have another separate publicly facing Traefik at the server / cluster level. This is the network that main Traefik lives in.
* `traefik_public_constraint_tag`: The tag that should be used by stack services that should communicate with the public. * `traefik_public_constraint_tag`: The tag that should be used by stack services that should communicate with the public.
* `flower_auth`: Basic HTTP authentication for flower, in the form`user:password`. By default: "`root:changethis`". * `flower_auth`: Basic HTTP authentication for flower, in the form`user:password`. By default: "`root:changethis`".
@@ -146,7 +145,32 @@ After using this generator, your new project (the directory created) will contai
## Release Notes ## Release Notes
### Next release ### Latest Changes
### 0.5.0
* Make the Traefik public network a fixed default of `traefik-public` as done in DockerSwarm.rocks, to simplify development and iteration of the project generator. PR [#150](https://github.com/tiangolo/full-stack-fastapi-postgresql/pull/150).
* Update to PostgreSQL 12. PR [#148](https://github.com/tiangolo/full-stack-fastapi-postgresql/pull/148). by [@RCheese](https://github.com/RCheese).
* Use Poetry for package management. Initial PR [#144](https://github.com/tiangolo/full-stack-fastapi-postgresql/pull/144) by [@RCheese](https://github.com/RCheese).
* Fix Windows line endings for shell scripts after project generation with Cookiecutter hooks. PR [#149](https://github.com/tiangolo/full-stack-fastapi-postgresql/pull/149).
* Upgrade Vue CLI to version 4. PR [#120](https://github.com/tiangolo/full-stack-fastapi-postgresql/pull/120) by [@br3ndonland](https://github.com/br3ndonland).
* Remove duplicate `login` tag. PR [#135](https://github.com/tiangolo/full-stack-fastapi-postgresql/pull/135) by [@Nonameentered](https://github.com/Nonameentered).
* Fix showing email in dashboard when there's no user's full name. PR [#129](https://github.com/tiangolo/full-stack-fastapi-postgresql/pull/129) by [@rlonka](https://github.com/rlonka).
* Format code with Black and Flake8. PR [#121](https://github.com/tiangolo/full-stack-fastapi-postgresql/pull/121) by [@br3ndonland](https://github.com/br3ndonland).
* Simplify SQLAlchemy Base class. PR [#117](https://github.com/tiangolo/full-stack-fastapi-postgresql/pull/117) by [@airibarne](https://github.com/airibarne).
* Update CRUD utils for users, handling password hashing. PR [#106](https://github.com/tiangolo/full-stack-fastapi-postgresql/pull/106) by [@mocsar](https://github.com/mocsar).
* Use `.` instead of `source` for interoperability. PR [#98](https://github.com/tiangolo/full-stack-fastapi-postgresql/pull/98) by [@gucharbon](https://github.com/gucharbon).
* Use Pydantic's `BaseSettings` for settings/configs and env vars. PR [#87](https://github.com/tiangolo/full-stack-fastapi-postgresql/pull/87) by [@StephenBrown2](https://github.com/StephenBrown2).
* Remove `package-lock.json` to let everyone lock their own versions (depending on OS, etc).
* Simplify Traefik service labels PR [#139](https://github.com/tiangolo/full-stack-fastapi-postgresql/pull/139).
* Add email validation. PR [#40](https://github.com/tiangolo/full-stack-fastapi-postgresql/pull/40) by [@kedod](https://github.com/kedod).
* Fix typo in README. PR [#83](https://github.com/tiangolo/full-stack-fastapi-postgresql/pull/83) by [@ashears](https://github.com/ashears).
* Fix typo in README. PR [#80](https://github.com/tiangolo/full-stack-fastapi-postgresql/pull/80) by [@abjoker](https://github.com/abjoker).
* Fix function name `read_item` and response code. PR [#74](https://github.com/tiangolo/full-stack-fastapi-postgresql/pull/74) by [@jcaguirre89](https://github.com/jcaguirre89).
* Fix typo in comment. PR [#70](https://github.com/tiangolo/full-stack-fastapi-postgresql/pull/70) by [@daniel-butler](https://github.com/daniel-butler).
* Fix Flower Docker configuration. PR [#37](https://github.com/tiangolo/full-stack-fastapi-postgresql/pull/37) by [@dmontagu](https://github.com/dmontagu).
* Add new CRUD utils based on DB and Pydantic models. Initial PR [#23](https://github.com/tiangolo/full-stack-fastapi-postgresql/pull/23) by [@ebreton](https://github.com/ebreton).
* Add normal user testing Pytest fixture. PR [#20](https://github.com/tiangolo/full-stack-fastapi-postgresql/pull/20) by [@ebreton](https://github.com/ebreton).
### 0.4.0 ### 0.4.0

View File

@@ -10,7 +10,7 @@
"secret_key": "changethis", "secret_key": "changethis",
"first_superuser": "admin@{{cookiecutter.domain_main}}", "first_superuser": "admin@{{cookiecutter.domain_main}}",
"first_superuser_password": "changethis", "first_superuser_password": "changethis",
"backend_cors_origins": "http://localhost, http://localhost:4200, http://localhost:3000, http://localhost:8080, https://localhost, https://localhost:4200, https://localhost:3000, https://localhost:8080, http://dev.{{cookiecutter.domain_main}}, https://{{cookiecutter.domain_staging}}, https://{{cookiecutter.domain_main}}, http://local.dockertoolbox.tiangolo.com, http://localhost.tiangolo.com", "backend_cors_origins": "[\"http://localhost\", \"http://localhost:4200\", \"http://localhost:3000\", \"http://localhost:8080\", \"https://localhost\", \"https://localhost:4200\", \"https://localhost:3000\", \"https://localhost:8080\", \"http://dev.{{cookiecutter.domain_main}}\", \"https://{{cookiecutter.domain_staging}}\", \"https://{{cookiecutter.domain_main}}\", \"http://local.dockertoolbox.tiangolo.com\", \"http://localhost.tiangolo.com\"]",
"smtp_port": "587", "smtp_port": "587",
"smtp_host": "", "smtp_host": "",
"smtp_user": "", "smtp_user": "",
@@ -23,7 +23,6 @@
"traefik_constraint_tag": "{{cookiecutter.domain_main}}", "traefik_constraint_tag": "{{cookiecutter.domain_main}}",
"traefik_constraint_tag_staging": "{{cookiecutter.domain_staging}}", "traefik_constraint_tag_staging": "{{cookiecutter.domain_staging}}",
"traefik_public_network": "traefik-public",
"traefik_public_constraint_tag": "traefik-public", "traefik_public_constraint_tag": "traefik-public",
"flower_auth": "admin:{{cookiecutter.first_superuser_password}}", "flower_auth": "admin:{{cookiecutter.first_superuser_password}}",

View File

@@ -0,0 +1,8 @@
from pathlib import Path
path: Path
for path in Path(".").glob("**/*.sh"):
data = path.read_bytes()
lf_data = data.replace(b"\r\n", b"\n")
path.write_bytes(lf_data)

View File

@@ -1,5 +1,5 @@
rm -rf \{\{cookiecutter.project_slug\}\}/.git rm -rf \{\{cookiecutter.project_slug\}\}/.git
rm -rf \{\{cookiecutter.project_slug\}\}/backend/app/Pipfile.lock rm -rf \{\{cookiecutter.project_slug\}\}/backend/app/poetry.lock
rm -rf \{\{cookiecutter.project_slug\}\}/frontend/node_modules rm -rf \{\{cookiecutter.project_slug\}\}/frontend/node_modules
rm -rf \{\{cookiecutter.project_slug\}\}/frontend/dist rm -rf \{\{cookiecutter.project_slug\}\}/frontend/dist
git checkout \{\{cookiecutter.project_slug\}\}/README.md git checkout \{\{cookiecutter.project_slug\}\}/README.md

View File

@@ -9,6 +9,6 @@ cookiecutter --config-file ./testing-config.yml --no-input -f ./
cd ./testing-project cd ./testing-project
bash ./scripts/test.sh bash ./scripts/test.sh "$@"
cd ../ cd ../

View File

@@ -6,8 +6,8 @@ DOMAIN=localhost
# DOMAIN=localhost.tiangolo.com # DOMAIN=localhost.tiangolo.com
# DOMAIN=dev.{{cookiecutter.domain_main}} # DOMAIN=dev.{{cookiecutter.domain_main}}
TRAEFIK_PUBLIC_NETWORK=traefik-public
TRAEFIK_TAG={{cookiecutter.traefik_constraint_tag}} TRAEFIK_TAG={{cookiecutter.traefik_constraint_tag}}
TRAEFIK_PUBLIC_NETWORK={{cookiecutter.traefik_public_network}}
TRAEFIK_PUBLIC_TAG={{cookiecutter.traefik_public_constraint_tag}} TRAEFIK_PUBLIC_TAG={{cookiecutter.traefik_public_constraint_tag}}
DOCKER_IMAGE_BACKEND={{cookiecutter.docker_image_backend}} DOCKER_IMAGE_BACKEND={{cookiecutter.docker_image_backend}}

View File

@@ -55,7 +55,7 @@ If your Docker is not running in `localhost` (the URLs above wouldn't work) chec
Open your editor at `./backend/app/` (instead of the project root: `./`), so that you see an `./app/` directory with your code inside. That way, your editor will be able to find all the imports, etc. Open your editor at `./backend/app/` (instead of the project root: `./`), so that you see an `./app/` directory with your code inside. That way, your editor will be able to find all the imports, etc.
Modify or add SQLAlchemy models in `./backend/app/app/db_models/`, Pydantic models in `./backend/app/app/models/`, API endpoints in `./backend/app/app/api/`, CRUD (Create, Read, Update, Delete) utils in `./backend/app/app/crud/`. The easiest might be to copy the ones for Items (models, endpoints, and CRUD utils) and update them to your needs. Modify or add SQLAlchemy models in `./backend/app/app/models/`, Pydantic schemas in `./backend/app/app/schemas/`, API endpoints in `./backend/app/app/api/`, CRUD (Create, Read, Update, Delete) utils in `./backend/app/app/crud/`. The easiest might be to copy the ones for Items (models, endpoints, and CRUD utils) and update them to your needs.
Add and modify tasks to the Celery worker in `./backend/app/app/worker.py`. Add and modify tasks to the Celery worker in `./backend/app/app/worker.py`.
@@ -134,6 +134,20 @@ If you need to install any additional package for the tests, add it to the file
If you use GitLab CI the tests will run automatically. If you use GitLab CI the tests will run automatically.
#### Local tests
Start the stack with this command:
```Bash
DOMAIN=backend sh ./scripts/test-local.sh
```
The `./backend/app` directory is mounted as a "host volume" inside the docker container (set in the file `docker-compose.dev.volumes.yml`).
You can rerun the test on live code:
```Bash
docker-compose exec backend-tests /tests-start.sh
```
#### Test running stack #### Test running stack
If your stack is already up and you just want to run the tests, you can use: If your stack is already up and you just want to run the tests, you can use:
@@ -205,7 +219,7 @@ Make sure you create a "revision" of your models and that you "upgrade" your dat
docker-compose exec backend bash docker-compose exec backend bash
``` ```
* If you created a new model in `./backend/app/app/db_models/`, make sure to import it in `./backend/app/app/db/base.py`, that Python module (`base.py`) that imports all the models will be used by Alembic. * If you created a new model in `./backend/app/app/models/`, make sure to import it in `./backend/app/app/db/base.py`, that Python module (`base.py`) that imports all the models will be used by Alembic.
* After changing a model (for example, adding a column), inside the container, create a revision, e.g.: * After changing a model (for example, adding a column), inside the container, create a revision, e.g.:
@@ -380,6 +394,24 @@ And you can use CI (continuous integration) systems to do it automatically.
But you have to configure a couple things first. But you have to configure a couple things first.
### Traefik network
This stack expects the public Traefik network to be named `traefik-public`, just as in the tutorial in <a href="https://dockerswarm.rocks" class="external-link" target="_blank">DockerSwarm.rocks</a>.
If you need to use a different Traefik public network name, update it in the `docker-compose.yml` files, in the section:
```YAML
networks:
traefik-public:
external: true
```
Change `traefik-public` to the name of the used Traefik network. And then update it in the file `.env`:
```bash
TRAEFIK_PUBLIC_NETWORK=traefik-public
```
### Persisting Docker named volumes ### Persisting Docker named volumes
You need to make sure that each service (Docker container) that uses a volume is always deployed to the same Docker "node" in the cluster, that way it will preserve the data. Otherwise, it could be deployed to a different node each time, and each time the volume would be created in that new node before starting the service. As a result, it would look like your service was starting from scratch every time, losing all the previous data. You need to make sure that each service (Docker container) that uses a volume is always deployed to the same Docker "node" in the cluster, that way it will preserve the data. Otherwise, it could be deployed to a different node each time, and each time the volume would be created in that new node before starting the service. As a result, it would look like your service was starting from scratch every time, losing all the previous data.
@@ -388,7 +420,6 @@ That's specially important for a service running a database. But the same proble
To solve that, you can put constraints in the services that use one or more data volumes (like databases) to make them be deployed to a Docker node with a specific label. And of course, you need to have that label assigned to one (only one) of your nodes. To solve that, you can put constraints in the services that use one or more data volumes (like databases) to make them be deployed to a Docker node with a specific label. And of course, you need to have that label assigned to one (only one) of your nodes.
#### Adding services with volumes #### Adding services with volumes
For each service that uses a volume (databases, services with uploaded files, etc) you should have a label constraint in your `docker-compose.deploy.volumes-placement.yml` file. For each service that uses a volume (databases, services with uploaded files, etc) you should have a label constraint in your `docker-compose.deploy.volumes-placement.yml` file.

View File

@@ -1 +1,2 @@
__pycache__ __pycache__
app.egg-info

View File

@@ -1,39 +0,0 @@
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true
[dev-packages]
mypy = "*"
black = "*"
jupyter = "*"
isort = "*"
autoflake = "*"
flake8 = "*"
pytest = "*"
vulture = "*"
[packages]
fastapi = "*"
uvicorn = "*"
pyjwt = "*"
python-multipart = "*"
email-validator = "*"
requests = "*"
celery = "*"
passlib = {extras = ["bcrypt"],version = "*"}
tenacity = "*"
pydantic = "*"
emails = "*"
raven = "*"
gunicorn = "*"
jinja2 = "*"
psycopg2-binary = "*"
alembic = "*"
sqlalchemy = "*"
[requires]
python_version = "3.6"
[pipenv]
allow_prereleases = true

View File

@@ -67,11 +67,9 @@ def run_migrations_online():
""" """
configuration = config.get_section(config.config_ini_section) configuration = config.get_section(config.config_ini_section)
configuration['sqlalchemy.url'] = get_url() configuration["sqlalchemy.url"] = get_url()
connectable = engine_from_config( connectable = engine_from_config(
configuration, configuration, prefix="sqlalchemy.", poolclass=pool.NullPool,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
) )
with connectable.connect() as connection: with connectable.connect() as connection:

View File

@@ -1,7 +1,7 @@
"""First revision """First revision
Revision ID: d4867f3a4c0a Revision ID: d4867f3a4c0a
Revises: Revises:
Create Date: 2019-04-17 13:53:32.978401 Create Date: 2019-04-17 13:53:32.978401
""" """
@@ -10,7 +10,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = 'd4867f3a4c0a' revision = "d4867f3a4c0a"
down_revision = None down_revision = None
branch_labels = None branch_labels = None
depends_on = None depends_on = None
@@ -18,40 +18,42 @@ depends_on = None
def upgrade(): def upgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_table('user', op.create_table(
sa.Column('id', sa.Integer(), nullable=False), "user",
sa.Column('full_name', sa.String(), nullable=True), sa.Column("id", sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=True), sa.Column("full_name", sa.String(), nullable=True),
sa.Column('hashed_password', sa.String(), nullable=True), sa.Column("email", sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True), sa.Column("hashed_password", sa.String(), nullable=True),
sa.Column('is_superuser', sa.Boolean(), nullable=True), sa.Column("is_active", sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id') sa.Column("is_superuser", sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint("id"),
) )
op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True) op.create_index(op.f("ix_user_email"), "user", ["email"], unique=True)
op.create_index(op.f('ix_user_full_name'), 'user', ['full_name'], unique=False) op.create_index(op.f("ix_user_full_name"), "user", ["full_name"], unique=False)
op.create_index(op.f('ix_user_id'), 'user', ['id'], unique=False) op.create_index(op.f("ix_user_id"), "user", ["id"], unique=False)
op.create_table('item', op.create_table(
sa.Column('id', sa.Integer(), nullable=False), "item",
sa.Column('title', sa.String(), nullable=True), sa.Column("id", sa.Integer(), nullable=False),
sa.Column('description', sa.String(), nullable=True), sa.Column("title", sa.String(), nullable=True),
sa.Column('owner_id', sa.Integer(), nullable=True), sa.Column("description", sa.String(), nullable=True),
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ), sa.Column("owner_id", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint('id') sa.ForeignKeyConstraint(["owner_id"], ["user.id"],),
sa.PrimaryKeyConstraint("id"),
) )
op.create_index(op.f('ix_item_description'), 'item', ['description'], unique=False) op.create_index(op.f("ix_item_description"), "item", ["description"], unique=False)
op.create_index(op.f('ix_item_id'), 'item', ['id'], unique=False) op.create_index(op.f("ix_item_id"), "item", ["id"], unique=False)
op.create_index(op.f('ix_item_title'), 'item', ['title'], unique=False) op.create_index(op.f("ix_item_title"), "item", ["title"], unique=False)
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade(): def downgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_item_title'), table_name='item') op.drop_index(op.f("ix_item_title"), table_name="item")
op.drop_index(op.f('ix_item_id'), table_name='item') op.drop_index(op.f("ix_item_id"), table_name="item")
op.drop_index(op.f('ix_item_description'), table_name='item') op.drop_index(op.f("ix_item_description"), table_name="item")
op.drop_table('item') op.drop_table("item")
op.drop_index(op.f('ix_user_id'), table_name='user') op.drop_index(op.f("ix_user_id"), table_name="user")
op.drop_index(op.f('ix_user_full_name'), table_name='user') op.drop_index(op.f("ix_user_full_name"), table_name="user")
op.drop_index(op.f('ix_user_email'), table_name='user') op.drop_index(op.f("ix_user_email"), table_name="user")
op.drop_table('user') op.drop_table("user")
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@@ -6,8 +6,8 @@ from sqlalchemy.orm import Session
from app import crud from app import crud
from app.api.utils.db import get_db from app.api.utils.db import get_db
from app.api.utils.security import get_current_active_user from app.api.utils.security import get_current_active_user
from app.db_models.user import User as DBUser from app.models.user import User as DBUser
from app.models.item import Item, ItemCreate, ItemUpdate from app.schemas.item import Item, ItemCreate, ItemUpdate
router = APIRouter() router = APIRouter()
@@ -41,7 +41,9 @@ def create_item(
""" """
Create new item. Create new item.
""" """
item = crud.item.create(db_session=db, item_in=item_in, owner_id=current_user.id) item = crud.item.create_with_owner(
db_session=db, obj_in=item_in, owner_id=current_user.id
)
return item return item
@@ -61,12 +63,12 @@ def update_item(
raise HTTPException(status_code=404, detail="Item not found") raise HTTPException(status_code=404, detail="Item not found")
if not crud.user.is_superuser(current_user) and (item.owner_id != current_user.id): if not crud.user.is_superuser(current_user) and (item.owner_id != current_user.id):
raise HTTPException(status_code=400, detail="Not enough permissions") raise HTTPException(status_code=400, detail="Not enough permissions")
item = crud.item.update(db_session=db, item=item, item_in=item_in) item = crud.item.update(db_session=db, db_obj=item, obj_in=item_in)
return item return item
@router.get("/{id}", response_model=Item) @router.get("/{id}", response_model=Item)
def read_user_me( def read_item(
*, *,
db: Session = Depends(get_db), db: Session = Depends(get_db),
id: int, id: int,
@@ -77,7 +79,7 @@ def read_user_me(
""" """
item = crud.item.get(db_session=db, id=id) item = crud.item.get(db_session=db, id=id)
if not item: if not item:
raise HTTPException(status_code=400, detail="Item not found") raise HTTPException(status_code=404, detail="Item not found")
if not crud.user.is_superuser(current_user) and (item.owner_id != current_user.id): if not crud.user.is_superuser(current_user) and (item.owner_id != current_user.id):
raise HTTPException(status_code=400, detail="Not enough permissions") raise HTTPException(status_code=400, detail="Not enough permissions")
return item return item

View File

@@ -7,13 +7,13 @@ from sqlalchemy.orm import Session
from app import crud from app import crud
from app.api.utils.db import get_db from app.api.utils.db import get_db
from app.api.utils.security import get_current_user from app.api.utils.security import get_current_user
from app.core import config from app.core.config import settings
from app.core.jwt import create_access_token from app.core.jwt import create_access_token
from app.core.security import get_password_hash from app.core.security import get_password_hash
from app.db_models.user import User as DBUser from app.models.user import User as DBUser
from app.models.msg import Msg from app.schemas.msg import Msg
from app.models.token import Token from app.schemas.token import Token
from app.models.user import User from app.schemas.user import User
from app.utils import ( from app.utils import (
generate_password_reset_token, generate_password_reset_token,
send_reset_password_email, send_reset_password_email,
@@ -23,7 +23,7 @@ from app.utils import (
router = APIRouter() router = APIRouter()
@router.post("/login/access-token", response_model=Token, tags=["login"]) @router.post("/login/access-token", response_model=Token)
def login_access_token( def login_access_token(
db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends() db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends()
): ):
@@ -37,7 +37,7 @@ def login_access_token(
raise HTTPException(status_code=400, detail="Incorrect email or password") raise HTTPException(status_code=400, detail="Incorrect email or password")
elif not crud.user.is_active(user): elif not crud.user.is_active(user):
raise HTTPException(status_code=400, detail="Inactive user") raise HTTPException(status_code=400, detail="Inactive user")
access_token_expires = timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES) access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return { return {
"access_token": create_access_token( "access_token": create_access_token(
data={"user_id": user.id}, expires_delta=access_token_expires data={"user_id": user.id}, expires_delta=access_token_expires
@@ -46,7 +46,7 @@ def login_access_token(
} }
@router.post("/login/test-token", tags=["login"], response_model=User) @router.post("/login/test-token", response_model=User)
def test_token(current_user: DBUser = Depends(get_current_user)): def test_token(current_user: DBUser = Depends(get_current_user)):
""" """
Test access token Test access token
@@ -54,7 +54,7 @@ def test_token(current_user: DBUser = Depends(get_current_user)):
return current_user return current_user
@router.post("/password-recovery/{email}", tags=["login"], response_model=Msg) @router.post("/password-recovery/{email}", response_model=Msg)
def recover_password(email: str, db: Session = Depends(get_db)): def recover_password(email: str, db: Session = Depends(get_db)):
""" """
Password Recovery Password Recovery
@@ -73,8 +73,10 @@ def recover_password(email: str, db: Session = Depends(get_db)):
return {"msg": "Password recovery email sent"} return {"msg": "Password recovery email sent"}
@router.post("/reset-password/", tags=["login"], response_model=Msg) @router.post("/reset-password/", response_model=Msg)
def reset_password(token: str = Body(...), new_password: str = Body(...), db: Session = Depends(get_db)): def reset_password(
token: str = Body(...), new_password: str = Body(...), db: Session = Depends(get_db)
):
""" """
Reset password Reset password
""" """

View File

@@ -2,15 +2,15 @@ from typing import List
from fastapi import APIRouter, Body, Depends, HTTPException from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from pydantic.types import EmailStr from pydantic.networks import EmailStr
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import crud from app import crud
from app.api.utils.db import get_db from app.api.utils.db import get_db
from app.api.utils.security import get_current_active_superuser, get_current_active_user from app.api.utils.security import get_current_active_superuser, get_current_active_user
from app.core import config from app.core.config import settings
from app.db_models.user import User as DBUser from app.models.user import User as DBUser
from app.models.user import User, UserCreate, UserInDB, UserUpdate from app.schemas.user import User, UserCreate, UserUpdate
from app.utils import send_new_account_email from app.utils import send_new_account_email
router = APIRouter() router = APIRouter()
@@ -46,8 +46,8 @@ def create_user(
status_code=400, status_code=400,
detail="The user with this username already exists in the system.", detail="The user with this username already exists in the system.",
) )
user = crud.user.create(db, user_in=user_in) user = crud.user.create(db, obj_in=user_in)
if config.EMAILS_ENABLED and user_in.email: if settings.EMAILS_ENABLED and user_in.email:
send_new_account_email( send_new_account_email(
email_to=user_in.email, username=user_in.email, password=user_in.password email_to=user_in.email, username=user_in.email, password=user_in.password
) )
@@ -74,7 +74,7 @@ def update_user_me(
user_in.full_name = full_name user_in.full_name = full_name
if email is not None: if email is not None:
user_in.email = email user_in.email = email
user = crud.user.update(db, user=current_user, user_in=user_in) user = crud.user.update(db, db_obj=current_user, obj_in=user_in)
return user return user
@@ -100,10 +100,10 @@ def create_user_open(
""" """
Create new user without the need to be logged in. Create new user without the need to be logged in.
""" """
if not config.USERS_OPEN_REGISTRATION: if not settings.USERS_OPEN_REGISTRATION:
raise HTTPException( raise HTTPException(
status_code=403, status_code=403,
detail="Open user resgistration is forbidden on this server", detail="Open user registration is forbidden on this server",
) )
user = crud.user.get_by_email(db, email=email) user = crud.user.get_by_email(db, email=email)
if user: if user:
@@ -112,7 +112,7 @@ def create_user_open(
detail="The user with this username already exists in the system", detail="The user with this username already exists in the system",
) )
user_in = UserCreate(password=password, email=email, full_name=full_name) user_in = UserCreate(password=password, email=email, full_name=full_name)
user = crud.user.create(db, user_in=user_in) user = crud.user.create(db, obj_in=user_in)
return user return user
@@ -125,7 +125,7 @@ def read_user_by_id(
""" """
Get a specific user by id. Get a specific user by id.
""" """
user = crud.user.get(db, user_id=user_id) user = crud.user.get(db, id=user_id)
if user == current_user: if user == current_user:
return user return user
if not crud.user.is_superuser(current_user): if not crud.user.is_superuser(current_user):
@@ -141,16 +141,16 @@ def update_user(
db: Session = Depends(get_db), db: Session = Depends(get_db),
user_id: int, user_id: int,
user_in: UserUpdate, user_in: UserUpdate,
current_user: UserInDB = Depends(get_current_active_superuser), current_user: DBUser = Depends(get_current_active_superuser),
): ):
""" """
Update a user. Update a user.
""" """
user = crud.user.get(db, user_id=user_id) user = crud.user.get(db, id=user_id)
if not user: if not user:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail="The user with this username does not exist in the system", detail="The user with this username does not exist in the system",
) )
user = crud.user.update(db, user=user, user_in=user_in) user = crud.user.update(db, db_obj=user, obj_in=user_in)
return user return user

View File

@@ -1,19 +1,18 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic.types import EmailStr from pydantic.networks import EmailStr
from app.api.utils.security import get_current_active_superuser from app.api.utils.security import get_current_active_superuser
from app.core.celery_app import celery_app from app.core.celery_app import celery_app
from app.models.msg import Msg from app.schemas.msg import Msg
from app.models.user import UserInDB from app.schemas.user import User # noqa: F401
from app.models.user import User as DBUser
from app.utils import send_test_email from app.utils import send_test_email
router = APIRouter() router = APIRouter()
@router.post("/test-celery/", response_model=Msg, status_code=201) @router.post("/test-celery/", response_model=Msg, status_code=201)
def test_celery( def test_celery(msg: Msg, current_user: DBUser = Depends(get_current_active_superuser)):
msg: Msg, current_user: UserInDB = Depends(get_current_active_superuser)
):
""" """
Test Celery worker. Test Celery worker.
""" """
@@ -23,7 +22,7 @@ def test_celery(
@router.post("/test-email/", response_model=Msg, status_code=201) @router.post("/test-email/", response_model=Msg, status_code=201)
def test_email( def test_email(
email_to: EmailStr, current_user: UserInDB = Depends(get_current_active_superuser) email_to: EmailStr, current_user: DBUser = Depends(get_current_active_superuser)
): ):
""" """
Test emails. Test emails.

View File

@@ -7,25 +7,25 @@ from starlette.status import HTTP_403_FORBIDDEN
from app import crud from app import crud
from app.api.utils.db import get_db from app.api.utils.db import get_db
from app.core import config from app.core.config import settings
from app.core.jwt import ALGORITHM from app.core.jwt import ALGORITHM
from app.db_models.user import User from app.models.user import User
from app.models.token import TokenPayload from app.schemas.token import TokenPayload
reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="/api/v1/login/access-token") reusable_oauth2 = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/login/access-token")
def get_current_user( def get_current_user(
db: Session = Depends(get_db), token: str = Security(reusable_oauth2) db: Session = Depends(get_db), token: str = Security(reusable_oauth2)
): ):
try: try:
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[ALGORITHM]) payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
token_data = TokenPayload(**payload) token_data = TokenPayload(**payload)
except PyJWTError: except PyJWTError:
raise HTTPException( raise HTTPException(
status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials" status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials"
) )
user = crud.user.get(db, user_id=token_data.user_id) user = crud.user.get(db, id=token_data.user_id)
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
return user return user

View File

@@ -1,53 +1,92 @@
import os import secrets
from typing import List
from pydantic import AnyHttpUrl, BaseSettings, EmailStr, HttpUrl, PostgresDsn, validator
def getenv_boolean(var_name, default_value=False): class Settings(BaseSettings):
result = default_value
env_value = os.getenv(var_name) API_V1_STR: str = "/api/v1"
if env_value is not None:
result = env_value.upper() in ("TRUE", "1") SECRET_KEY: str = secrets.token_urlsafe(32)
return result
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 60 minutes * 24 hours * 8 days = 8 days
SERVER_NAME: str
SERVER_HOST: AnyHttpUrl
# BACKEND_CORS_ORIGINS is a JSON-formatted list of origins
# e.g: '["http://localhost", "http://localhost:4200", "http://localhost:3000", \
# "http://localhost:8080", "http://local.dockertoolbox.tiangolo.com"]'
BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
@validator("BACKEND_CORS_ORIGINS", pre=True)
def assemble_cors_origins(cls, v):
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",")]
return v
PROJECT_NAME: str
SENTRY_DSN: HttpUrl = None
@validator("SENTRY_DSN", pre=True)
def sentry_dsn_can_be_blank(cls, v):
if len(v) == 0:
return None
return v
POSTGRES_SERVER: str
POSTGRES_USER: str
POSTGRES_PASSWORD: str
POSTGRES_DB: str
SQLALCHEMY_DATABASE_URI: PostgresDsn = None
@validator("SQLALCHEMY_DATABASE_URI", pre=True)
def assemble_db_connection(cls, v, values):
if isinstance(v, str):
return v
return PostgresDsn.build(
scheme="postgresql",
user=values.get("POSTGRES_USER"),
password=values.get("POSTGRES_PASSWORD"),
host=values.get("POSTGRES_SERVER"),
path=f"/{values.get('POSTGRES_DB') or ''}",
)
SMTP_TLS: bool = True
SMTP_PORT: int = None
SMTP_HOST: str = None
SMTP_USER: str = None
SMTP_PASSWORD: str = None
EMAILS_FROM_EMAIL: EmailStr = None
EMAILS_FROM_NAME: str = None
@validator("EMAILS_FROM_NAME")
def get_project_name(cls, v, values):
if not v:
return values["PROJECT_NAME"]
return v
EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48
EMAIL_TEMPLATES_DIR: str = "/app/app/email-templates/build"
EMAILS_ENABLED: bool = False
@validator("EMAILS_ENABLED", pre=True)
def get_emails_enabled(cls, v, values):
return bool(
values.get("SMTP_HOST")
and values.get("SMTP_PORT")
and values.get("EMAILS_FROM_EMAIL")
)
EMAIL_TEST_USER: EmailStr = "test@example.com"
FIRST_SUPERUSER: EmailStr
FIRST_SUPERUSER_PASSWORD: str
USERS_OPEN_REGISTRATION: bool = False
class Config:
case_sensitive = True
API_V1_STR = "/api/v1" settings = Settings()
SECRET_KEY = os.getenvb(b"SECRET_KEY")
if not SECRET_KEY:
SECRET_KEY = os.urandom(32)
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 8 # 60 minutes * 24 hours * 8 days = 8 days
SERVER_NAME = os.getenv("SERVER_NAME")
SERVER_HOST = os.getenv("SERVER_HOST")
BACKEND_CORS_ORIGINS = os.getenv(
"BACKEND_CORS_ORIGINS"
) # a string of origins separated by commas, e.g: "http://localhost, http://localhost:4200, http://localhost:3000, http://localhost:8080, http://local.dockertoolbox.tiangolo.com"
PROJECT_NAME = os.getenv("PROJECT_NAME")
SENTRY_DSN = os.getenv("SENTRY_DSN")
POSTGRES_SERVER = os.getenv("POSTGRES_SERVER")
POSTGRES_USER = os.getenv("POSTGRES_USER")
POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD")
POSTGRES_DB = os.getenv("POSTGRES_DB")
SQLALCHEMY_DATABASE_URI = (
f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_SERVER}/{POSTGRES_DB}"
)
SMTP_TLS = getenv_boolean("SMTP_TLS", True)
SMTP_PORT = None
_SMTP_PORT = os.getenv("SMTP_PORT")
if _SMTP_PORT is not None:
SMTP_PORT = int(_SMTP_PORT)
SMTP_HOST = os.getenv("SMTP_HOST")
SMTP_USER = os.getenv("SMTP_USER")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
EMAILS_FROM_EMAIL = os.getenv("EMAILS_FROM_EMAIL")
EMAILS_FROM_NAME = PROJECT_NAME
EMAIL_RESET_TOKEN_EXPIRE_HOURS = 48
EMAIL_TEMPLATES_DIR = "/app/app/email-templates/build"
EMAILS_ENABLED = SMTP_HOST and SMTP_PORT and EMAILS_FROM_EMAIL
FIRST_SUPERUSER = os.getenv("FIRST_SUPERUSER")
FIRST_SUPERUSER_PASSWORD = os.getenv("FIRST_SUPERUSER_PASSWORD")
USERS_OPEN_REGISTRATION = getenv_boolean("USERS_OPEN_REGISTRATION")

View File

@@ -2,7 +2,7 @@ from datetime import datetime, timedelta
import jwt import jwt
from app.core import config from app.core.config import settings
ALGORITHM = "HS256" ALGORITHM = "HS256"
access_token_jwt_subject = "access" access_token_jwt_subject = "access"
@@ -15,5 +15,5 @@ def create_access_token(*, data: dict, expires_delta: timedelta = None):
else: else:
expire = datetime.utcnow() + timedelta(minutes=15) expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire, "sub": access_token_jwt_subject}) to_encode.update({"exp": expire, "sub": access_token_jwt_subject})
encoded_jwt = jwt.encode(to_encode, config.SECRET_KEY, algorithm=ALGORITHM) encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt return encoded_jwt

View File

@@ -1 +1,10 @@
from . import item, user from .crud_user import user # noqa: F401
from .crud_item import item # noqa: F401
# For a new basic set of CRUD operations you could just do
# from .base import CRUDBase
# from app.models.item import Item
# from app.schemas.item import ItemCreate, ItemUpdate
# item = CRUDBase[Item, ItemCreate, ItemUpdate](Item)

View File

@@ -0,0 +1,57 @@
from typing import List, Optional, Generic, TypeVar, Type
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.db.base_class import Base
ModelType = TypeVar("ModelType", bound=Base)
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
def __init__(self, model: Type[ModelType]):
"""
CRUD object with default methods to Create, Read, Update, Delete (CRUD).
**Parameters**
* `model`: A SQLAlchemy model class
* `schema`: A Pydantic model (schema) class
"""
self.model = model
def get(self, db_session: Session, id: int) -> Optional[ModelType]:
return db_session.query(self.model).filter(self.model.id == id).first()
def get_multi(self, db_session: Session, *, skip=0, limit=100) -> List[ModelType]:
return db_session.query(self.model).offset(skip).limit(limit).all()
def create(self, db_session: Session, *, obj_in: CreateSchemaType) -> ModelType:
obj_in_data = jsonable_encoder(obj_in)
db_obj = self.model(**obj_in_data)
db_session.add(db_obj)
db_session.commit()
db_session.refresh(db_obj)
return db_obj
def update(
self, db_session: Session, *, db_obj: ModelType, obj_in: UpdateSchemaType
) -> ModelType:
obj_data = jsonable_encoder(db_obj)
update_data = obj_in.dict(exclude_unset=True)
for field in obj_data:
if field in update_data:
setattr(db_obj, field, update_data[field])
db_session.add(db_obj)
db_session.commit()
db_session.refresh(db_obj)
return db_obj
def remove(self, db_session: Session, *, id: int) -> ModelType:
obj = db_session.query(self.model).get(id)
db_session.delete(obj)
db_session.commit()
return obj

View File

@@ -0,0 +1,34 @@
from typing import List
from fastapi.encoders import jsonable_encoder
from sqlalchemy.orm import Session
from app.models.item import Item
from app.schemas.item import ItemCreate, ItemUpdate
from app.crud.base import CRUDBase
class CRUDItem(CRUDBase[Item, ItemCreate, ItemUpdate]):
def create_with_owner(
self, db_session: Session, *, obj_in: ItemCreate, owner_id: int
) -> Item:
obj_in_data = jsonable_encoder(obj_in)
db_obj = self.model(**obj_in_data, owner_id=owner_id)
db_session.add(db_obj)
db_session.commit()
db_session.refresh(db_obj)
return db_obj
def get_multi_by_owner(
self, db_session: Session, *, owner_id: int, skip=0, limit=100
) -> List[Item]:
return (
db_session.query(self.model)
.filter(Item.owner_id == owner_id)
.offset(skip)
.limit(limit)
.all()
)
item = CRUDItem(Item)

View File

@@ -0,0 +1,53 @@
from typing import Optional
from sqlalchemy.orm import Session
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate, UserInDB
from app.core.security import verify_password, get_password_hash
from app.crud.base import CRUDBase
class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]):
def get_by_email(self, db_session: Session, *, email: str) -> Optional[User]:
return db_session.query(User).filter(User.email == email).first()
def create(self, db_session: Session, *, obj_in: UserCreate) -> User:
db_obj = User(
email=obj_in.email,
hashed_password=get_password_hash(obj_in.password),
full_name=obj_in.full_name,
is_superuser=obj_in.is_superuser,
)
db_session.add(db_obj)
db_session.commit()
db_session.refresh(db_obj)
return db_obj
def update(self, db_session: Session, *, db_obj: User, obj_in: UserUpdate) -> User:
if obj_in.password:
update_data = obj_in.dict(exclude_unset=True)
hashed_password = get_password_hash(obj_in.password)
del update_data["password"]
update_data["hashed_password"] = hashed_password
use_obj_in = UserInDB.parse_obj(update_data)
return super().update(db_session, db_obj=db_obj, obj_in=use_obj_in)
def authenticate(
self, db_session: Session, *, email: str, password: str
) -> Optional[User]:
user = self.get_by_email(db_session, email=email)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def is_active(self, user: User) -> bool:
return user.is_active
def is_superuser(self, user: User) -> bool:
return user.is_superuser
user = CRUDUser(User)

View File

@@ -1,55 +0,0 @@
from typing import List, Optional
from fastapi.encoders import jsonable_encoder
from sqlalchemy.orm import Session
from app.db_models.item import Item
from app.models.item import ItemCreate, ItemUpdate
def get(db_session: Session, *, id: int) -> Optional[Item]:
return db_session.query(Item).filter(Item.id == id).first()
def get_multi(db_session: Session, *, skip=0, limit=100) -> List[Optional[Item]]:
return db_session.query(Item).offset(skip).limit(limit).all()
def get_multi_by_owner(
db_session: Session, *, owner_id: int, skip=0, limit=100
) -> List[Optional[Item]]:
return (
db_session.query(Item)
.filter(Item.owner_id == owner_id)
.offset(skip)
.limit(limit)
.all()
)
def create(db_session: Session, *, item_in: ItemCreate, owner_id: int) -> Item:
item_in_data = jsonable_encoder(item_in)
item = Item(**item_in_data, owner_id=owner_id)
db_session.add(item)
db_session.commit()
db_session.refresh(item)
return item
def update(db_session: Session, *, item: Item, item_in: ItemUpdate) -> Item:
item_data = jsonable_encoder(item)
update_data = item_in.dict(skip_defaults=True)
for field in item_data:
if field in update_data:
setattr(item, field, update_data[field])
db_session.add(item)
db_session.commit()
db_session.refresh(item)
return item
def remove(db_session: Session, *, id: int):
item = db_session.query(Item).filter(Item.id == id).first()
db_session.delete(item)
db_session.commit()
return item

View File

@@ -1,65 +0,0 @@
from typing import List, Optional
from fastapi.encoders import jsonable_encoder
from sqlalchemy.orm import Session
from app.core.security import get_password_hash, verify_password
from app.db_models.user import User
from app.models.user import UserCreate, UserUpdate
def get(db_session: Session, *, user_id: int) -> Optional[User]:
return db_session.query(User).filter(User.id == user_id).first()
def get_by_email(db_session: Session, *, email: str) -> Optional[User]:
return db_session.query(User).filter(User.email == email).first()
def authenticate(db_session: Session, *, email: str, password: str) -> Optional[User]:
user = get_by_email(db_session, email=email)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def is_active(user) -> bool:
return user.is_active
def is_superuser(user) -> bool:
return user.is_superuser
def get_multi(db_session: Session, *, skip=0, limit=100) -> List[Optional[User]]:
return db_session.query(User).offset(skip).limit(limit).all()
def create(db_session: Session, *, user_in: UserCreate) -> User:
user = User(
email=user_in.email,
hashed_password=get_password_hash(user_in.password),
full_name=user_in.full_name,
is_superuser=user_in.is_superuser,
)
db_session.add(user)
db_session.commit()
db_session.refresh(user)
return user
def update(db_session: Session, *, user: User, user_in: UserUpdate) -> User:
user_data = jsonable_encoder(user)
update_data = user_in.dict(skip_defaults=True)
for field in user_data:
if field in update_data:
setattr(user, field, update_data[field])
if user_in.password:
passwordhash = get_password_hash(user_in.password)
user.hashed_password = passwordhash
db_session.add(user)
db_session.commit()
db_session.refresh(user)
return user

View File

@@ -1,5 +1,5 @@
# Import all the models, so that Base has them before being # Import all the models, so that Base has them before being
# imported by Alembic # imported by Alembic
from app.db.base_class import Base # noqa from app.db.base_class import Base # noqa
from app.db_models.user import User # noqa from app.models.user import User # noqa
from app.db_models.item import Item # noqa from app.models.item import Item # noqa

View File

@@ -1,11 +1,9 @@
from sqlalchemy.ext.declarative import declarative_base, declared_attr from sqlalchemy.ext.declarative import as_declarative, declared_attr
class CustomBase(object): @as_declarative()
class Base:
# Generate __tablename__ automatically # Generate __tablename__ automatically
@declared_attr @declared_attr
def __tablename__(cls): def __tablename__(cls):
return cls.__name__.lower() return cls.__name__.lower()
Base = declarative_base(cls=CustomBase)

View File

@@ -1,11 +1,11 @@
from app import crud from app import crud
from app.core import config from app.core.config import settings
from app.models.user import UserCreate from app.schemas.user import UserCreate
# make sure all SQL Alchemy models are imported before initializing DB # make sure all SQL Alchemy models are imported before initializing DB
# otherwise, SQL Alchemy might fail to initialize properly relationships # otherwise, SQL Alchemy might fail to initialize relationships properly
# for more details: https://github.com/tiangolo/full-stack-fastapi-postgresql/issues/28 # for more details: https://github.com/tiangolo/full-stack-fastapi-postgresql/issues/28
from app.db import base from app.db import base # noqa: F401
def init_db(db_session): def init_db(db_session):
@@ -14,11 +14,11 @@ def init_db(db_session):
# the tables un-commenting the next line # the tables un-commenting the next line
# Base.metadata.create_all(bind=engine) # Base.metadata.create_all(bind=engine)
user = crud.user.get_by_email(db_session, email=config.FIRST_SUPERUSER) user = crud.user.get_by_email(db_session, email=settings.FIRST_SUPERUSER)
if not user: if not user:
user_in = UserCreate( user_in = UserCreate(
email=config.FIRST_SUPERUSER, email=settings.FIRST_SUPERUSER,
password=config.FIRST_SUPERUSER_PASSWORD, password=settings.FIRST_SUPERUSER_PASSWORD,
is_superuser=True, is_superuser=True,
) )
user = crud.user.create(db_session, user_in=user_in) user = crud.user.create(db_session, obj_in=user_in) # noqa: F841

View File

@@ -1,9 +1,9 @@
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker from sqlalchemy.orm import scoped_session, sessionmaker
from app.core import config from app.core.config import settings
engine = create_engine(config.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True) engine = create_engine(settings.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True)
db_session = scoped_session( db_session = scoped_session(
sessionmaker(autocommit=False, autoflush=False, bind=engine) sessionmaker(autocommit=False, autoflush=False, bind=engine)
) )

View File

@@ -1,12 +0,0 @@
from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from app.db.base_class import Base
class Item(Base):
id = Column(Integer, primary_key=True, index=True)
title = Column(String, index=True)
description = Column(String, index=True)
owner_id = Column(Integer, ForeignKey("user.id"))
owner = relationship("User", back_populates="items")

View File

@@ -1,14 +0,0 @@
from sqlalchemy import Boolean, Column, Integer, String
from sqlalchemy.orm import relationship
from app.db.base_class import Base
class User(Base):
id = Column(Integer, primary_key=True, index=True)
full_name = Column(String, index=True)
email = Column(String, unique=True, index=True)
hashed_password = Column(String)
is_active = Column(Boolean(), default=True)
is_superuser = Column(Boolean(), default=False)
items = relationship("Item", back_populates="owner")

View File

@@ -3,29 +3,22 @@ from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request from starlette.requests import Request
from app.api.api_v1.api import api_router from app.api.api_v1.api import api_router
from app.core import config from app.core.config import settings
from app.db.session import Session from app.db.session import Session
app = FastAPI(title=config.PROJECT_NAME, openapi_url="/api/v1/openapi.json") app = FastAPI(title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json")
# CORS
origins = []
# Set all CORS enabled origins # Set all CORS enabled origins
if config.BACKEND_CORS_ORIGINS: if settings.BACKEND_CORS_ORIGINS:
origins_raw = config.BACKEND_CORS_ORIGINS.split(",")
for origin in origins_raw:
use_origin = origin.strip()
origins.append(use_origin)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=origins, allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
), ),
app.include_router(api_router, prefix=config.API_V1_STR) app.include_router(api_router, prefix=settings.API_V1_STR)
@app.middleware("http") @app.middleware("http")

View File

@@ -1,34 +1,12 @@
from pydantic import BaseModel from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from app.db.base_class import Base
# Shared properties class Item(Base):
class ItemBase(BaseModel): id = Column(Integer, primary_key=True, index=True)
title: str = None title = Column(String, index=True)
description: str = None description = Column(String, index=True)
owner_id = Column(Integer, ForeignKey("user.id"))
owner = relationship("User", back_populates="items")
# Properties to receive on item creation
class ItemCreate(ItemBase):
title: str
# Properties to receive on item update
class ItemUpdate(ItemBase):
pass
# Properties shared by models stored in DB
class ItemInDBBase(ItemBase):
id: int
title: str
owner_id: int
# Properties to return to client
class Item(ItemInDBBase):
pass
# Properties properties stored in DB
class ItemInDB(ItemInDBBase):
pass

View File

@@ -1,36 +1,14 @@
from typing import Optional from sqlalchemy import Boolean, Column, Integer, String
from sqlalchemy.orm import relationship
from pydantic import BaseModel from app.db.base_class import Base
# Shared properties class User(Base):
class UserBase(BaseModel): id = Column(Integer, primary_key=True, index=True)
email: Optional[str] = None full_name = Column(String, index=True)
is_active: Optional[bool] = True email = Column(String, unique=True, index=True)
is_superuser: Optional[bool] = False hashed_password = Column(String)
full_name: Optional[str] = None is_active = Column(Boolean(), default=True)
is_superuser = Column(Boolean(), default=False)
items = relationship("Item", back_populates="owner")
class UserBaseInDB(UserBase):
id: int = None
# Properties to receive via API on creation
class UserCreate(UserBaseInDB):
email: str
password: str
# Properties to receive via API on update
class UserUpdate(UserBaseInDB):
password: Optional[str] = None
# Additional properties to return via API
class User(UserBaseInDB):
pass
# Additional properties stored in DB
class UserInDB(UserBaseInDB):
hashed_password: str

View File

@@ -0,0 +1,39 @@
from pydantic import BaseModel
from .user import User # noqa: F401
# Shared properties
class ItemBase(BaseModel):
title: str = None
description: str = None
# Properties to receive on item creation
class ItemCreate(ItemBase):
title: str
# Properties to receive on item update
class ItemUpdate(ItemBase):
pass
# Properties shared by models stored in DB
class ItemInDBBase(ItemBase):
id: int
title: str
owner_id: int
class Config:
orm_mode = True
# Properties to return to client
class Item(ItemInDBBase):
pass
# Properties properties stored in DB
class ItemInDB(ItemInDBBase):
pass

View File

@@ -0,0 +1,39 @@
from typing import Optional
from pydantic import BaseModel, EmailStr
# Shared properties
class UserBase(BaseModel):
email: Optional[EmailStr] = None
is_active: Optional[bool] = True
is_superuser: Optional[bool] = False
full_name: Optional[str] = None
# Properties to receive via API on creation
class UserCreate(UserBase):
email: EmailStr
password: str
# Properties to receive via API on update
class UserUpdate(UserBase):
password: Optional[str] = None
class UserInDBBase(UserBase):
id: int = None
class Config:
orm_mode = True
# Additional properties to return via API
class User(UserInDBBase):
pass
# Additional properties stored in DB
class UserInDB(UserInDBBase):
hashed_password: str

View File

@@ -1,6 +1,6 @@
import requests import requests
from app.core import config from app.core.config import settings
from app.tests.utils.utils import get_server_api from app.tests.utils.utils import get_server_api
@@ -8,7 +8,7 @@ def test_celery_worker_test(superuser_token_headers):
server_api = get_server_api() server_api = get_server_api()
data = {"msg": "test"} data = {"msg": "test"}
r = requests.post( r = requests.post(
f"{server_api}{config.API_V1_STR}/utils/test-celery/", f"{server_api}{settings.API_V1_STR}/utils/test-celery/",
json=data, json=data,
headers=superuser_token_headers, headers=superuser_token_headers,
) )

View File

@@ -1,18 +1,20 @@
import requests import requests
from app.core import config from app.core.config import settings
from app.tests.utils.item import create_random_item from app.tests.utils.item import create_random_item
from app.tests.utils.utils import get_server_api from app.tests.utils.utils import get_server_api
from app.tests.utils.user import create_random_user # noqa: F401
def test_create_item(superuser_token_headers): def test_create_item(superuser_token_headers):
server_api = get_server_api() server_api = get_server_api()
data = {"title": "Foo", "description": "Fighters"} data = {"title": "Foo", "description": "Fighters"}
response = requests.post( response = requests.post(
f"{server_api}{config.API_V1_STR}/items/", f"{server_api}{settings.API_V1_STR}/items/",
headers=superuser_token_headers, headers=superuser_token_headers,
json=data, json=data,
) )
assert response.status_code == 200
content = response.json() content = response.json()
assert content["title"] == data["title"] assert content["title"] == data["title"]
assert content["description"] == data["description"] assert content["description"] == data["description"]
@@ -24,9 +26,10 @@ def test_read_item(superuser_token_headers):
item = create_random_item() item = create_random_item()
server_api = get_server_api() server_api = get_server_api()
response = requests.get( response = requests.get(
f"{server_api}{config.API_V1_STR}/items/{item.id}", f"{server_api}{settings.API_V1_STR}/items/{item.id}",
headers=superuser_token_headers, headers=superuser_token_headers,
) )
assert response.status_code == 200
content = response.json() content = response.json()
assert content["title"] == item.title assert content["title"] == item.title
assert content["description"] == item.description assert content["description"] == item.description

View File

@@ -1,17 +1,17 @@
import requests import requests
from app.core import config from app.core.config import settings
from app.tests.utils.utils import get_server_api from app.tests.utils.utils import get_server_api
def test_get_access_token(): def test_get_access_token():
server_api = get_server_api() server_api = get_server_api()
login_data = { login_data = {
"username": config.FIRST_SUPERUSER, "username": settings.FIRST_SUPERUSER,
"password": config.FIRST_SUPERUSER_PASSWORD, "password": settings.FIRST_SUPERUSER_PASSWORD,
} }
r = requests.post( r = requests.post(
f"{server_api}{config.API_V1_STR}/login/access-token", data=login_data f"{server_api}{settings.API_V1_STR}/login/access-token", data=login_data
) )
tokens = r.json() tokens = r.json()
assert r.status_code == 200 assert r.status_code == 200
@@ -22,7 +22,7 @@ def test_get_access_token():
def test_use_access_token(superuser_token_headers): def test_use_access_token(superuser_token_headers):
server_api = get_server_api() server_api = get_server_api()
r = requests.post( r = requests.post(
f"{server_api}{config.API_V1_STR}/login/test-token", f"{server_api}{settings.API_V1_STR}/login/test-token",
headers=superuser_token_headers, headers=superuser_token_headers,
) )
result = r.json() result = r.json()

View File

@@ -1,32 +1,43 @@
import requests import requests
from app import crud from app import crud
from app.core import config from app.core.config import settings
from app.db.session import db_session from app.db.session import db_session
from app.models.user import UserCreate from app.schemas.user import UserCreate
from app.tests.utils.user import user_authentication_headers from app.tests.utils.utils import get_server_api, random_lower_string, random_email
from app.tests.utils.utils import get_server_api, random_lower_string
def test_get_users_superuser_me(superuser_token_headers): def test_get_users_superuser_me(superuser_token_headers):
server_api = get_server_api() server_api = get_server_api()
r = requests.get( r = requests.get(
f"{server_api}{config.API_V1_STR}/users/me", headers=superuser_token_headers f"{server_api}{settings.API_V1_STR}/users/me", headers=superuser_token_headers
) )
current_user = r.json() current_user = r.json()
assert current_user assert current_user
assert current_user["is_active"] is True assert current_user["is_active"] is True
assert current_user["is_superuser"] assert current_user["is_superuser"]
assert current_user["email"] == config.FIRST_SUPERUSER assert current_user["email"] == settings.FIRST_SUPERUSER
def test_get_users_normal_user_me(normal_user_token_headers):
server_api = get_server_api()
r = requests.get(
f"{server_api}{settings.API_V1_STR}/users/me", headers=normal_user_token_headers
)
current_user = r.json()
assert current_user
assert current_user["is_active"] is True
assert current_user["is_superuser"] is False
assert current_user["email"] == settings.EMAIL_TEST_USER
def test_create_user_new_email(superuser_token_headers): def test_create_user_new_email(superuser_token_headers):
server_api = get_server_api() server_api = get_server_api()
username = random_lower_string() username = random_email()
password = random_lower_string() password = random_lower_string()
data = {"email": username, "password": password} data = {"email": username, "password": password}
r = requests.post( r = requests.post(
f"{server_api}{config.API_V1_STR}/users/", f"{server_api}{settings.API_V1_STR}/users/",
headers=superuser_token_headers, headers=superuser_token_headers,
json=data, json=data,
) )
@@ -38,13 +49,13 @@ def test_create_user_new_email(superuser_token_headers):
def test_get_existing_user(superuser_token_headers): def test_get_existing_user(superuser_token_headers):
server_api = get_server_api() server_api = get_server_api()
username = random_lower_string() username = random_email()
password = random_lower_string() password = random_lower_string()
user_in = UserCreate(email=username, password=password) user_in = UserCreate(email=username, password=password)
user = crud.user.create(db_session, user_in=user_in) user = crud.user.create(db_session, obj_in=user_in)
user_id = user.id user_id = user.id
r = requests.get( r = requests.get(
f"{server_api}{config.API_V1_STR}/users/{user_id}", f"{server_api}{settings.API_V1_STR}/users/{user_id}",
headers=superuser_token_headers, headers=superuser_token_headers,
) )
assert 200 <= r.status_code < 300 assert 200 <= r.status_code < 300
@@ -55,14 +66,14 @@ def test_get_existing_user(superuser_token_headers):
def test_create_user_existing_username(superuser_token_headers): def test_create_user_existing_username(superuser_token_headers):
server_api = get_server_api() server_api = get_server_api()
username = random_lower_string() username = random_email()
# username = email # username = email
password = random_lower_string() password = random_lower_string()
user_in = UserCreate(email=username, password=password) user_in = UserCreate(email=username, password=password)
user = crud.user.create(db_session, user_in=user_in) crud.user.create(db_session, obj_in=user_in)
data = {"email": username, "password": password} data = {"email": username, "password": password}
r = requests.post( r = requests.post(
f"{server_api}{config.API_V1_STR}/users/", f"{server_api}{settings.API_V1_STR}/users/",
headers=superuser_token_headers, headers=superuser_token_headers,
json=data, json=data,
) )
@@ -71,34 +82,33 @@ def test_create_user_existing_username(superuser_token_headers):
assert "_id" not in created_user assert "_id" not in created_user
def test_create_user_by_normal_user(): def test_create_user_by_normal_user(normal_user_token_headers):
server_api = get_server_api() server_api = get_server_api()
username = random_lower_string() username = random_email()
password = random_lower_string() password = random_lower_string()
user_in = UserCreate(email=username, password=password)
user = crud.user.create(db_session, user_in=user_in)
user_token_headers = user_authentication_headers(server_api, username, password)
data = {"email": username, "password": password} data = {"email": username, "password": password}
r = requests.post( r = requests.post(
f"{server_api}{config.API_V1_STR}/users/", headers=user_token_headers, json=data f"{server_api}{settings.API_V1_STR}/users/",
headers=normal_user_token_headers,
json=data,
) )
assert r.status_code == 400 assert r.status_code == 400
def test_retrieve_users(superuser_token_headers): def test_retrieve_users(superuser_token_headers):
server_api = get_server_api() server_api = get_server_api()
username = random_lower_string() username = random_email()
password = random_lower_string() password = random_lower_string()
user_in = UserCreate(email=username, password=password) user_in = UserCreate(email=username, password=password)
user = crud.user.create(db_session, user_in=user_in) user = crud.user.create(db_session, obj_in=user_in)
username2 = random_lower_string() username2 = random_email()
password2 = random_lower_string() password2 = random_lower_string()
user_in2 = UserCreate(email=username2, password=password2) user_in2 = UserCreate(email=username2, password=password2)
user2 = crud.user.create(db_session, user_in=user_in2) crud.user.create(db_session, obj_in=user_in2)
r = requests.get( r = requests.get(
f"{server_api}{config.API_V1_STR}/users/", headers=superuser_token_headers f"{server_api}{settings.API_V1_STR}/users/", headers=superuser_token_headers
) )
all_users = r.json() all_users = r.json()

View File

@@ -1,6 +1,8 @@
import pytest import pytest
from app.core.config import settings
from app.tests.utils.utils import get_server_api, get_superuser_token_headers from app.tests.utils.utils import get_server_api, get_superuser_token_headers
from app.tests.utils.user import authentication_token_from_email
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
@@ -11,3 +13,8 @@ def server_api():
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def superuser_token_headers(): def superuser_token_headers():
return get_superuser_token_headers() return get_superuser_token_headers()
@pytest.fixture(scope="module")
def normal_user_token_headers():
return authentication_token_from_email(settings.EMAIL_TEST_USER)

View File

@@ -1,5 +1,5 @@
from app import crud from app import crud
from app.models.item import ItemCreate, ItemUpdate from app.schemas.item import ItemCreate, ItemUpdate
from app.tests.utils.user import create_random_user from app.tests.utils.user import create_random_user
from app.tests.utils.utils import random_lower_string from app.tests.utils.utils import random_lower_string
from app.db.session import db_session from app.db.session import db_session
@@ -10,7 +10,9 @@ def test_create_item():
description = random_lower_string() description = random_lower_string()
item_in = ItemCreate(title=title, description=description) item_in = ItemCreate(title=title, description=description)
user = create_random_user() user = create_random_user()
item = crud.item.create(db_session=db_session, item_in=item_in, owner_id=user.id) item = crud.item.create_with_owner(
db_session=db_session, obj_in=item_in, owner_id=user.id
)
assert item.title == title assert item.title == title
assert item.description == description assert item.description == description
assert item.owner_id == user.id assert item.owner_id == user.id
@@ -21,7 +23,9 @@ def test_get_item():
description = random_lower_string() description = random_lower_string()
item_in = ItemCreate(title=title, description=description) item_in = ItemCreate(title=title, description=description)
user = create_random_user() user = create_random_user()
item = crud.item.create(db_session=db_session, item_in=item_in, owner_id=user.id) item = crud.item.create_with_owner(
db_session=db_session, obj_in=item_in, owner_id=user.id
)
stored_item = crud.item.get(db_session=db_session, id=item.id) stored_item = crud.item.get(db_session=db_session, id=item.id)
assert item.id == stored_item.id assert item.id == stored_item.id
assert item.title == stored_item.title assert item.title == stored_item.title
@@ -34,12 +38,12 @@ def test_update_item():
description = random_lower_string() description = random_lower_string()
item_in = ItemCreate(title=title, description=description) item_in = ItemCreate(title=title, description=description)
user = create_random_user() user = create_random_user()
item = crud.item.create(db_session=db_session, item_in=item_in, owner_id=user.id) item = crud.item.create_with_owner(
db_session=db_session, obj_in=item_in, owner_id=user.id
)
description2 = random_lower_string() description2 = random_lower_string()
item_update = ItemUpdate(description=description2) item_update = ItemUpdate(description=description2)
item2 = crud.item.update( item2 = crud.item.update(db_session=db_session, db_obj=item, obj_in=item_update)
db_session=db_session, item=item, item_in=item_update
)
assert item.id == item2.id assert item.id == item2.id
assert item.title == item2.title assert item.title == item2.title
assert item2.description == description2 assert item2.description == description2
@@ -51,7 +55,9 @@ def test_delete_item():
description = random_lower_string() description = random_lower_string()
item_in = ItemCreate(title=title, description=description) item_in = ItemCreate(title=title, description=description)
user = create_random_user() user = create_random_user()
item = crud.item.create(db_session=db_session, item_in=item_in, owner_id=user.id) item = crud.item.create_with_owner(
db_session=db_session, obj_in=item_in, owner_id=user.id
)
item2 = crud.item.remove(db_session=db_session, id=item.id) item2 = crud.item.remove(db_session=db_session, id=item.id)
item3 = crud.item.get(db_session=db_session, id=item.id) item3 = crud.item.get(db_session=db_session, id=item.id)
assert item3 is None assert item3 is None

View File

@@ -1,25 +1,26 @@
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from app import crud from app import crud
from app.core.security import get_password_hash, verify_password
from app.db.session import db_session from app.db.session import db_session
from app.models.user import UserCreate from app.schemas.user import UserCreate, UserUpdate
from app.tests.utils.utils import random_lower_string from app.tests.utils.utils import random_lower_string, random_email
def test_create_user(): def test_create_user():
email = random_lower_string() email = random_email()
password = random_lower_string() password = random_lower_string()
user_in = UserCreate(email=email, password=password) user_in = UserCreate(email=email, password=password)
user = crud.user.create(db_session, user_in=user_in) user = crud.user.create(db_session, obj_in=user_in)
assert user.email == email assert user.email == email
assert hasattr(user, "hashed_password") assert hasattr(user, "hashed_password")
def test_authenticate_user(): def test_authenticate_user():
email = random_lower_string() email = random_email()
password = random_lower_string() password = random_lower_string()
user_in = UserCreate(email=email, password=password) user_in = UserCreate(email=email, password=password)
user = crud.user.create(db_session, user_in=user_in) user = crud.user.create(db_session, obj_in=user_in)
authenticated_user = crud.user.authenticate( authenticated_user = crud.user.authenticate(
db_session, email=email, password=password db_session, email=email, password=password
) )
@@ -28,56 +29,66 @@ def test_authenticate_user():
def test_not_authenticate_user(): def test_not_authenticate_user():
email = random_lower_string() email = random_email()
password = random_lower_string() password = random_lower_string()
user = crud.user.authenticate(db_session, email=email, password=password) user = crud.user.authenticate(db_session, email=email, password=password)
assert user is None assert user is None
def test_check_if_user_is_active(): def test_check_if_user_is_active():
email = random_lower_string() email = random_email()
password = random_lower_string() password = random_lower_string()
user_in = UserCreate(email=email, password=password) user_in = UserCreate(email=email, password=password)
user = crud.user.create(db_session, user_in=user_in) user = crud.user.create(db_session, obj_in=user_in)
is_active = crud.user.is_active(user) is_active = crud.user.is_active(user)
assert is_active is True assert is_active is True
def test_check_if_user_is_active_inactive(): def test_check_if_user_is_active_inactive():
email = random_lower_string() email = random_email()
password = random_lower_string() password = random_lower_string()
user_in = UserCreate(email=email, password=password, disabled=True) user_in = UserCreate(email=email, password=password, disabled=True)
print(user_in) user = crud.user.create(db_session, obj_in=user_in)
user = crud.user.create(db_session, user_in=user_in)
print(user)
is_active = crud.user.is_active(user) is_active = crud.user.is_active(user)
print(is_active)
assert is_active assert is_active
def test_check_if_user_is_superuser(): def test_check_if_user_is_superuser():
email = random_lower_string() email = random_email()
password = random_lower_string() password = random_lower_string()
user_in = UserCreate(email=email, password=password, is_superuser=True) user_in = UserCreate(email=email, password=password, is_superuser=True)
user = crud.user.create(db_session, user_in=user_in) user = crud.user.create(db_session, obj_in=user_in)
is_superuser = crud.user.is_superuser(user) is_superuser = crud.user.is_superuser(user)
assert is_superuser is True assert is_superuser is True
def test_check_if_user_is_superuser_normal_user(): def test_check_if_user_is_superuser_normal_user():
username = random_lower_string() username = random_email()
password = random_lower_string() password = random_lower_string()
user_in = UserCreate(email=username, password=password) user_in = UserCreate(email=username, password=password)
user = crud.user.create(db_session, user_in=user_in) user = crud.user.create(db_session, obj_in=user_in)
is_superuser = crud.user.is_superuser(user) is_superuser = crud.user.is_superuser(user)
assert is_superuser is False assert is_superuser is False
def test_get_user(): def test_get_user():
password = random_lower_string() password = random_lower_string()
username = random_lower_string() username = random_email()
user_in = UserCreate(email=username, password=password, is_superuser=True) user_in = UserCreate(email=username, password=password, is_superuser=True)
user = crud.user.create(db_session, user_in=user_in) user = crud.user.create(db_session, obj_in=user_in)
user_2 = crud.user.get(db_session, user_id=user.id) user_2 = crud.user.get(db_session, id=user.id)
assert user.email == user_2.email assert user.email == user_2.email
assert jsonable_encoder(user) == jsonable_encoder(user_2) assert jsonable_encoder(user) == jsonable_encoder(user_2)
def test_update_user():
password = random_lower_string()
email = random_email()
user_in = UserCreate(email=email, password=password, is_superuser=True)
user = crud.user.create(db_session, obj_in=user_in)
new_password = random_lower_string()
user_in = UserUpdate(password=new_password, is_superuser=True)
crud.user.update(db_session, db_obj=user, obj_in=user_in)
user_2 = crud.user.get(db_session, id=user.id)
assert user.email == user_2.email
assert verify_password(new_password, user_2.hashed_password)

View File

@@ -1,6 +1,6 @@
from app import crud from app import crud
from app.db.session import db_session from app.db.session import db_session
from app.models.item import ItemCreate from app.schemas.item import ItemCreate
from app.tests.utils.user import create_random_user from app.tests.utils.user import create_random_user
from app.tests.utils.utils import random_lower_string from app.tests.utils.utils import random_lower_string
@@ -12,6 +12,6 @@ def create_random_item(owner_id: int = None):
title = random_lower_string() title = random_lower_string()
description = random_lower_string() description = random_lower_string()
item_in = ItemCreate(title=title, description=description, id=id) item_in = ItemCreate(title=title, description=description, id=id)
return crud.item.create( return crud.item.create_with_owner(
db_session=db_session, item_in=item_in, owner_id=owner_id db_session=db_session, obj_in=item_in, owner_id=owner_id
) )

View File

@@ -1,16 +1,16 @@
import requests import requests
from app import crud from app import crud
from app.core import config from app.core.config import settings
from app.db.session import db_session from app.db.session import db_session
from app.models.user import UserCreate from app.schemas.user import UserCreate, UserUpdate
from app.tests.utils.utils import random_lower_string from app.tests.utils.utils import get_server_api, random_lower_string, random_email
def user_authentication_headers(server_api, email, password): def user_authentication_headers(server_api, email, password):
data = {"username": email, "password": password} data = {"username": email, "password": password}
r = requests.post(f"{server_api}{config.API_V1_STR}/login/access-token", data=data) r = requests.post(f"{server_api}{settings.API_V1_STR}/login/access-token", data=data)
response = r.json() response = r.json()
auth_token = response["access_token"] auth_token = response["access_token"]
headers = {"Authorization": f"Bearer {auth_token}"} headers = {"Authorization": f"Bearer {auth_token}"}
@@ -18,8 +18,26 @@ def user_authentication_headers(server_api, email, password):
def create_random_user(): def create_random_user():
email = random_lower_string() email = random_email()
password = random_lower_string() password = random_lower_string()
user_in = UserCreate(username=email, email=email, password=password) user_in = UserCreate(username=email, email=email, password=password)
user = crud.user.create(db_session=db_session, user_in=user_in) user = crud.user.create(db_session=db_session, obj_in=user_in)
return user return user
def authentication_token_from_email(email):
"""
Return a valid token for the user with given email.
If the user doesn't exist it is created first.
"""
password = random_lower_string()
user = crud.user.get_by_email(db_session, email=email)
if not user:
user_in = UserCreate(username=email, email=email, password=password)
user = crud.user.create(db_session=db_session, obj_in=user_in)
else:
user_in = UserUpdate(password=password)
user = crud.user.update(db_session, db_obj=user, obj_in=user_in)
return user_authentication_headers(get_server_api(), email, password)

View File

@@ -3,26 +3,30 @@ import string
import requests import requests
from app.core import config from app.core.config import settings
def random_lower_string(): def random_lower_string():
return "".join(random.choices(string.ascii_lowercase, k=32)) return "".join(random.choices(string.ascii_lowercase, k=32))
def random_email():
return f"{random_lower_string()}@{random_lower_string()}.com"
def get_server_api(): def get_server_api():
server_name = f"http://{config.SERVER_NAME}" server_name = f"http://{settings.SERVER_NAME}"
return server_name return server_name
def get_superuser_token_headers(): def get_superuser_token_headers():
server_api = get_server_api() server_api = get_server_api()
login_data = { login_data = {
"username": config.FIRST_SUPERUSER, "username": settings.FIRST_SUPERUSER,
"password": config.FIRST_SUPERUSER_PASSWORD, "password": settings.FIRST_SUPERUSER_PASSWORD,
} }
r = requests.post( r = requests.post(
f"{server_api}{config.API_V1_STR}/login/access-token", data=login_data f"{server_api}{settings.API_V1_STR}/login/access-token", data=login_data
) )
tokens = r.json() tokens = r.json()
a_token = tokens["access_token"] a_token = tokens["access_token"]

View File

@@ -8,79 +8,79 @@ import jwt
from emails.template import JinjaTemplate from emails.template import JinjaTemplate
from jwt.exceptions import InvalidTokenError from jwt.exceptions import InvalidTokenError
from app.core import config from app.core.config import settings
password_reset_jwt_subject = "preset" password_reset_jwt_subject = "preset"
def send_email(email_to: str, subject_template="", html_template="", environment={}): def send_email(email_to: str, subject_template="", html_template="", environment={}):
assert config.EMAILS_ENABLED, "no provided configuration for email variables" assert settings.EMAILS_ENABLED, "no provided configuration for email variables"
message = emails.Message( message = emails.Message(
subject=JinjaTemplate(subject_template), subject=JinjaTemplate(subject_template),
html=JinjaTemplate(html_template), html=JinjaTemplate(html_template),
mail_from=(config.EMAILS_FROM_NAME, config.EMAILS_FROM_EMAIL), mail_from=(settings.EMAILS_FROM_NAME, settings.EMAILS_FROM_EMAIL),
) )
smtp_options = {"host": config.SMTP_HOST, "port": config.SMTP_PORT} smtp_options = {"host": settings.SMTP_HOST, "port": settings.SMTP_PORT}
if config.SMTP_TLS: if settings.SMTP_TLS:
smtp_options["tls"] = True smtp_options["tls"] = True
if config.SMTP_USER: if settings.SMTP_USER:
smtp_options["user"] = config.SMTP_USER smtp_options["user"] = settings.SMTP_USER
if config.SMTP_PASSWORD: if settings.SMTP_PASSWORD:
smtp_options["password"] = config.SMTP_PASSWORD smtp_options["password"] = settings.SMTP_PASSWORD
response = message.send(to=email_to, render=environment, smtp=smtp_options) response = message.send(to=email_to, render=environment, smtp=smtp_options)
logging.info(f"send email result: {response}") logging.info(f"send email result: {response}")
def send_test_email(email_to: str): def send_test_email(email_to: str):
project_name = config.PROJECT_NAME project_name = settings.PROJECT_NAME
subject = f"{project_name} - Test email" subject = f"{project_name} - Test email"
with open(Path(config.EMAIL_TEMPLATES_DIR) / "test_email.html") as f: with open(Path(settings.EMAIL_TEMPLATES_DIR) / "test_email.html") as f:
template_str = f.read() template_str = f.read()
send_email( send_email(
email_to=email_to, email_to=email_to,
subject_template=subject, subject_template=subject,
html_template=template_str, html_template=template_str,
environment={"project_name": config.PROJECT_NAME, "email": email_to}, environment={"project_name": settings.PROJECT_NAME, "email": email_to},
) )
def send_reset_password_email(email_to: str, email: str, token: str): def send_reset_password_email(email_to: str, email: str, token: str):
project_name = config.PROJECT_NAME project_name = settings.PROJECT_NAME
subject = f"{project_name} - Password recovery for user {email}" subject = f"{project_name} - Password recovery for user {email}"
with open(Path(config.EMAIL_TEMPLATES_DIR) / "reset_password.html") as f: with open(Path(settings.EMAIL_TEMPLATES_DIR) / "reset_password.html") as f:
template_str = f.read() template_str = f.read()
if hasattr(token, "decode"): if hasattr(token, "decode"):
use_token = token.decode() use_token = token.decode()
else: else:
use_token = token use_token = token
server_host = config.SERVER_HOST server_host = settings.SERVER_HOST
link = f"{server_host}/reset-password?token={use_token}" link = f"{server_host}/reset-password?token={use_token}"
send_email( send_email(
email_to=email_to, email_to=email_to,
subject_template=subject, subject_template=subject,
html_template=template_str, html_template=template_str,
environment={ environment={
"project_name": config.PROJECT_NAME, "project_name": settings.PROJECT_NAME,
"username": email, "username": email,
"email": email_to, "email": email_to,
"valid_hours": config.EMAIL_RESET_TOKEN_EXPIRE_HOURS, "valid_hours": settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS,
"link": link, "link": link,
}, },
) )
def send_new_account_email(email_to: str, username: str, password: str): def send_new_account_email(email_to: str, username: str, password: str):
project_name = config.PROJECT_NAME project_name = settings.PROJECT_NAME
subject = f"{project_name} - New account for user {username}" subject = f"{project_name} - New account for user {username}"
with open(Path(config.EMAIL_TEMPLATES_DIR) / "new_account.html") as f: with open(Path(settings.EMAIL_TEMPLATES_DIR) / "new_account.html") as f:
template_str = f.read() template_str = f.read()
link = config.SERVER_HOST link = settings.SERVER_HOST
send_email( send_email(
email_to=email_to, email_to=email_to,
subject_template=subject, subject_template=subject,
html_template=template_str, html_template=template_str,
environment={ environment={
"project_name": config.PROJECT_NAME, "project_name": settings.PROJECT_NAME,
"username": username, "username": username,
"password": password, "password": password,
"email": email_to, "email": email_to,
@@ -90,13 +90,13 @@ def send_new_account_email(email_to: str, username: str, password: str):
def generate_password_reset_token(email): def generate_password_reset_token(email):
delta = timedelta(hours=config.EMAIL_RESET_TOKEN_EXPIRE_HOURS) delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS)
now = datetime.utcnow() now = datetime.utcnow()
expires = now + delta expires = now + delta
exp = expires.timestamp() exp = expires.timestamp()
encoded_jwt = jwt.encode( encoded_jwt = jwt.encode(
{"exp": exp, "nbf": now, "sub": password_reset_jwt_subject, "email": email}, {"exp": exp, "nbf": now, "sub": password_reset_jwt_subject, "email": email},
config.SECRET_KEY, settings.SECRET_KEY,
algorithm="HS256", algorithm="HS256",
) )
return encoded_jwt return encoded_jwt
@@ -104,7 +104,7 @@ def generate_password_reset_token(email):
def verify_password_reset_token(token) -> Optional[str]: def verify_password_reset_token(token) -> Optional[str]:
try: try:
decoded_token = jwt.decode(token, config.SECRET_KEY, algorithms=["HS256"]) decoded_token = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
assert decoded_token["sub"] == password_reset_jwt_subject assert decoded_token["sub"] == password_reset_jwt_subject
return decoded_token["email"] return decoded_token["email"]
except InvalidTokenError: except InvalidTokenError:

View File

@@ -1,9 +1,9 @@
from raven import Client from raven import Client
from app.core import config from app.core.config import settings
from app.core.celery_app import celery_app from app.core.celery_app import celery_app
client_sentry = Client(config.SENTRY_DSN) client_sentry = Client(settings.SENTRY_DSN)
@celery_app.task(acks_late=True) @celery_app.task(acks_late=True)

View File

@@ -0,0 +1,40 @@
[tool.poetry]
name = "app"
version = "0.1.0"
description = ""
authors = ["Admin <admin@example.com>"]
[tool.poetry.dependencies]
python = "^3.7"
uvicorn = "^0.11.3"
fastapi = "^0.54.1"
pyjwt = "^1.7.1"
python-multipart = "^0.0.5"
email-validator = "^1.0.5"
requests = "^2.23.0"
celery = "^4.4.2"
passlib = {extras = ["bcrypt"], version = "^1.7.2"}
tenacity = "^6.1.0"
pydantic = "^1.4"
emails = "^0.5.15"
raven = "^6.10.0"
gunicorn = "^20.0.4"
jinja2 = "^2.11.2"
psycopg2-binary = "^2.8.5"
alembic = "^1.4.2"
sqlalchemy = "^1.3.16"
pytest = "^5.4.1"
[tool.poetry.dev-dependencies]
mypy = "^0.770"
black = "^19.10b0"
isort = "^4.3.21"
autoflake = "^1.3.1"
flake8 = "^3.7.9"
pytest = "^5.4.1"
jupyter = "^1.0.0"
vulture = "^1.4"
[build-system]
requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api"

View File

@@ -1,6 +1,16 @@
FROM tiangolo/uvicorn-gunicorn-fastapi:python3.7 FROM tiangolo/uvicorn-gunicorn-fastapi:python3.7
RUN pip install celery~=4.3 passlib[bcrypt] tenacity requests emails "fastapi>=0.16.0" uvicorn gunicorn pyjwt python-multipart email_validator jinja2 psycopg2-binary alembic SQLAlchemy WORKDIR /app/
# Install Poetry
RUN curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | POETRY_HOME=/opt/poetry python && \
cd /usr/local/bin && \
ln -s /opt/poetry/bin/poetry && \
poetry config virtualenvs.create false
# Copy poetry.lock* in case it doesn't exist in the repo
COPY ./app/pyproject.toml ./app/poetry.lock* /app/
RUN poetry install --no-dev --no-root
# For development, Jupyter remote kernel, Hydrogen # For development, Jupyter remote kernel, Hydrogen
# Using inside the container: # Using inside the container:
@@ -10,7 +20,6 @@ RUN bash -c "if [ $env == 'dev' ] ; then pip install jupyterlab ; fi"
EXPOSE 8888 EXPOSE 8888
COPY ./app /app COPY ./app /app
WORKDIR /app/
ENV PYTHONPATH=/app ENV PYTHONPATH=/app

View File

@@ -1,6 +1,16 @@
FROM python:3.7 FROM python:3.7
RUN pip install raven celery~=4.3 passlib[bcrypt] tenacity requests "fastapi>=0.16.0" emails pyjwt email_validator jinja2 psycopg2-binary alembic SQLAlchemy WORKDIR /app/
# Install Poetry
RUN curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | POETRY_HOME=/opt/poetry python && \
cd /usr/local/bin && \
ln -s /opt/poetry/bin/poetry && \
poetry config virtualenvs.create false
# Copy poetry.lock* in case it doesn't exist in the repo
COPY ./app/pyproject.toml ./app/poetry.lock* /app/
RUN poetry install --no-dev --no-root
# For development, Jupyter remote kernel, Hydrogen # For development, Jupyter remote kernel, Hydrogen
# Using inside the container: # Using inside the container:

View File

@@ -1,6 +1,16 @@
FROM python:3.7 FROM python:3.7
RUN pip install requests pytest tenacity passlib[bcrypt] "fastapi>=0.16.0" psycopg2-binary SQLAlchemy WORKDIR /app/
# Install Poetry
RUN curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | POETRY_HOME=/opt/poetry python && \
cd /usr/local/bin && \
ln -s /opt/poetry/bin/poetry && \
poetry config virtualenvs.create false
# Copy poetry.lock* in case it doesn't exist in the repo
COPY ./app/pyproject.toml ./app/poetry.lock* /app/
RUN poetry install --no-dev --no-root
# For development, Jupyter remote kernel, Hydrogen # For development, Jupyter remote kernel, Hydrogen
# Using inside the container: # Using inside the container:

View File

@@ -19,7 +19,6 @@ default_context:
pgadmin_default_user_password: '{{ cookiecutter.pgadmin_default_user_password }}' pgadmin_default_user_password: '{{ cookiecutter.pgadmin_default_user_password }}'
traefik_constraint_tag: '{{ cookiecutter.traefik_constraint_tag }}' traefik_constraint_tag: '{{ cookiecutter.traefik_constraint_tag }}'
traefik_constraint_tag_staging: '{{ cookiecutter.traefik_constraint_tag_staging }}' traefik_constraint_tag_staging: '{{ cookiecutter.traefik_constraint_tag_staging }}'
traefik_public_network: '{{ cookiecutter.traefik_public_network }}'
traefik_public_constraint_tag: '{{ cookiecutter.traefik_public_constraint_tag }}' traefik_public_constraint_tag: '{{ cookiecutter.traefik_public_constraint_tag }}'
flower_auth: '{{ cookiecutter.flower_auth }}' flower_auth: '{{ cookiecutter.flower_auth }}'
sentry_dsn: '{{ cookiecutter.sentry_dsn }}' sentry_dsn: '{{ cookiecutter.sentry_dsn }}'

View File

@@ -8,11 +8,8 @@ services:
- traefik.port=5050 - traefik.port=5050
- traefik.tags=${TRAEFIK_PUBLIC_TAG} - traefik.tags=${TRAEFIK_PUBLIC_TAG}
- traefik.docker.network=${TRAEFIK_PUBLIC_NETWORK} - traefik.docker.network=${TRAEFIK_PUBLIC_NETWORK}
# Traefik service that listens to HTTP - traefik.frontend.entryPoints=http,https
- traefik.redirectorservice.frontend.entryPoints=http - traefik.frontend.redirect.entryPoint=https
- traefik.redirectorservice.frontend.redirect.entryPoint=https
# Traefik service that listens to HTTPS
- traefik.webservice.frontend.entryPoints=https
proxy: proxy:
deploy: deploy:
labels: labels:
@@ -25,18 +22,15 @@ services:
- traefik.port=80 - traefik.port=80
- traefik.tags=${TRAEFIK_PUBLIC_TAG} - traefik.tags=${TRAEFIK_PUBLIC_TAG}
- traefik.docker.network=${TRAEFIK_PUBLIC_NETWORK} - traefik.docker.network=${TRAEFIK_PUBLIC_NETWORK}
# Traefik service that listens to HTTP - traefik.frontend.entryPoints=http,https
- traefik.servicehttp.frontend.entryPoints=http - traefik.frontend.redirect.entryPoint=https
- traefik.servicehttp.frontend.redirect.entryPoint=https
# Traefik service that listens to HTTPS
- traefik.servicehttps.frontend.entryPoints=https
# Uncomment the config line below to detect and redirect www to non-www (or the contrary) # Uncomment the config line below to detect and redirect www to non-www (or the contrary)
# The lines above for traefik.frontend.rule are needed too # The lines above for traefik.frontend.rule are needed too
# - "traefik.servicehttps.frontend.redirect.regex=^https?://(www.)?(${DOMAIN})/(.*)" # - "traefik.frontend.redirect.regex=^https?://(www.)?(${DOMAIN})/(.*)"
# To redirect from non-www to www un-comment the line below # To redirect from non-www to www un-comment the line below
# - "traefik.servicehttps.frontend.redirect.replacement=https://www.${DOMAIN}/$$3" # - "traefik.frontend.redirect.replacement=https://www.${DOMAIN}/$$3"
# To redirect from www to non-www un-comment the line below # To redirect from www to non-www un-comment the line below
# - "traefik.servicehttps.frontend.redirect.replacement=https://${DOMAIN}/$$3" # - "traefik.frontend.redirect.replacement=https://${DOMAIN}/$$3"
flower: flower:
deploy: deploy:
labels: labels:
@@ -45,11 +39,8 @@ services:
- traefik.port=5555 - traefik.port=5555
- traefik.tags=${TRAEFIK_PUBLIC_TAG} - traefik.tags=${TRAEFIK_PUBLIC_TAG}
- traefik.docker.network=${TRAEFIK_PUBLIC_NETWORK} - traefik.docker.network=${TRAEFIK_PUBLIC_NETWORK}
# Traefik service that listens to HTTP - traefik.frontend.entryPoints=http,https
- traefik.redirectorservice.frontend.entryPoints=http - traefik.frontend.redirect.entryPoint=https
- traefik.redirectorservice.frontend.redirect.entryPoint=https
# Traefik service that listens to HTTPS
- traefik.webservice.frontend.entryPoints=https
backend: backend:
deploy: deploy:
labels: labels:

View File

@@ -14,5 +14,5 @@ services:
- default - default
networks: networks:
{{cookiecutter.traefik_public_network}}: traefik-public:
external: true external: true

View File

@@ -12,3 +12,4 @@ services:
backend-tests: backend-tests:
environment: environment:
- JUPYTER=jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888 - JUPYTER=jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888
- SERVER_HOST=http://${DOMAIN}

View File

@@ -11,6 +11,11 @@ services:
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
flower: flower:
image: totem/celery-flower-docker image: mher/flower
env_file: env_file:
- env-flower.env - env-flower.env
command:
- "--broker=amqp://guest@queue:5672//"
# For the "Broker" tab to work in the flower UI, uncomment the following command argument,
# and change the queue service's image as described in docker-compose.shared.base-images.yml
# - "--broker_api=http://guest:guest@queue:15672/api//"

View File

@@ -1,6 +1,10 @@
version: '3.3' version: '3.3'
services: services:
db: db:
image: postgres:11 image: postgres:12
queue: queue:
image: rabbitmq:3 image: rabbitmq:3
# Using the below image instead is required to enable the "Broker" tab in the flower UI:
# image: rabbitmq:3-management
#
# You also have to change the flower command as documented in docker-compose.shared.admin.yml

View File

@@ -17,4 +17,5 @@ services:
- env-postgres.env - env-postgres.env
- env-backend.env - env-backend.env
environment: environment:
- SERVER_NAME=${DOMAIN}
- SERVER_HOST=https://${DOMAIN} - SERVER_HOST=https://${DOMAIN}

View File

@@ -10,6 +10,7 @@ services:
- env-postgres.env - env-postgres.env
environment: environment:
- SERVER_NAME=backend - SERVER_NAME=backend
- SERVER_HOST=http://${DOMAIN}
backend: backend:
environment: environment:
# Don't send emails during testing # Don't send emails during testing

View File

@@ -1,3 +1 @@
FLOWER_BASIC_AUTH={{cookiecutter.flower_auth}} FLOWER_BASIC_AUTH={{cookiecutter.flower_auth}}
AMQP_ADMIN_HOST=queue
AMQP_HOST=queue

View File

@@ -1,7 +1,7 @@
module.exports = { module.exports = {
"presets": [ "presets": [
[ [
"@vue/app", "@vue/cli-plugin-babel/preset",
{ {
"useBuiltIns": "entry" "useBuiltIns": "entry"
} }

File diff suppressed because it is too large Load Diff

View File

@@ -5,12 +5,13 @@
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
"build": "vue-cli-service build", "build": "vue-cli-service build",
"lint": "vue-cli-service lint", "test:unit": "vue-cli-service test:unit",
"test:unit": "vue-cli-service test:unit" "lint": "vue-cli-service lint"
}, },
"dependencies": { "dependencies": {
"@babel/polyfill": "^7.2.5", "@babel/polyfill": "^7.2.5",
"axios": "^0.18.0", "axios": "^0.18.0",
"core-js": "^3.4.3",
"register-service-worker": "^1.0.0", "register-service-worker": "^1.0.0",
"typesafe-vuex": "^3.1.1", "typesafe-vuex": "^3.1.1",
"vee-validate": "^2.1.7", "vee-validate": "^2.1.7",
@@ -23,16 +24,16 @@
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^23.3.13", "@types/jest": "^23.3.13",
"@vue/cli-plugin-babel": "^3.3.0", "@vue/cli-plugin-babel": "^4.1.1",
"@vue/cli-plugin-pwa": "^3.3.0", "@vue/cli-plugin-pwa": "^4.1.1",
"@vue/cli-plugin-typescript": "^3.3.0", "@vue/cli-plugin-typescript": "^4.1.1",
"@vue/cli-plugin-unit-jest": "^3.5.0", "@vue/cli-plugin-unit-jest": "^4.1.1",
"@vue/cli-service": "^3.3.1", "@vue/cli-service": "^4.1.1",
"@vue/test-utils": "^1.0.0-beta.28", "@vue/test-utils": "^1.0.0-beta.28",
"babel-core": "7.0.0-bridge.0", "babel-core": "7.0.0-bridge.0",
"ts-jest": "^23.10.5", "ts-jest": "^23.10.5",
"typescript": "^3.2.4", "typescript": "^3.2.4",
"vue-cli-plugin-vuetify": "^0.2.1", "vue-cli-plugin-vuetify": "^2.0.2",
"vue-template-compiler": "^2.5.22" "vue-template-compiler": "^2.5.22"
}, },
"postcss": { "postcss": {

View File

@@ -25,7 +25,7 @@ import { readUserProfile } from '@/store/main/getters';
export default class Dashboard extends Vue { export default class Dashboard extends Vue {
get greetedUser() { get greetedUser() {
const userProfile = readUserProfile(this.$store); const userProfile = readUserProfile(this.$store);
if (userProfile && userProfile.full_name) { if (userProfile) {
if (userProfile.full_name) { if (userProfile.full_name) {
return userProfile.full_name; return userProfile.full_name;
} else { } else {

View File

@@ -5,6 +5,6 @@ set -e
TAG=${TAG} \ TAG=${TAG} \
FRONTEND_ENV=${FRONTEND_ENV-production} \ FRONTEND_ENV=${FRONTEND_ENV-production} \
source ./scripts/build.sh . ./scripts/build.sh
docker-compose -f docker-stack.yml push docker-compose -f docker-stack.yml push

View File

@@ -27,4 +27,4 @@ docker-compose \
docker-compose -f docker-stack.yml build docker-compose -f docker-stack.yml build
docker-compose -f docker-stack.yml down -v --remove-orphans # Remove possibly previous broken stacks left hanging after an error docker-compose -f docker-stack.yml down -v --remove-orphans # Remove possibly previous broken stacks left hanging after an error
docker-compose -f docker-stack.yml up -d docker-compose -f docker-stack.yml up -d
docker-compose -f docker-stack.yml exec -T backend-tests /tests-start.sh docker-compose -f docker-stack.yml exec -T backend-tests /tests-start.sh "$@"

View File

@@ -15,5 +15,5 @@ config > docker-stack.yml
docker-compose -f docker-stack.yml build docker-compose -f docker-stack.yml build
docker-compose -f docker-stack.yml down -v --remove-orphans # Remove possibly previous broken stacks left hanging after an error docker-compose -f docker-stack.yml down -v --remove-orphans # Remove possibly previous broken stacks left hanging after an error
docker-compose -f docker-stack.yml up -d docker-compose -f docker-stack.yml up -d
docker-compose -f docker-stack.yml exec -T backend-tests /tests-start.sh docker-compose -f docker-stack.yml exec -T backend-tests /tests-start.sh "$@"
docker-compose -f docker-stack.yml down -v --remove-orphans docker-compose -f docker-stack.yml down -v --remove-orphans