├── .github ├── dependabot.yml └── workflows │ └── issue-manager.yml ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── THIRD-PARTY-NOTICES.txt ├── cookiecutter.json ├── docs ├── authentication-guide.md ├── deployment-guide.md ├── development-guide.md ├── getting-started.md └── websocket-guide.md ├── hooks └── post_gen_project.py ├── img ├── dashboard.png ├── docker_example.png ├── landing.png ├── login.png ├── redoc.png └── totp.png ├── scripts ├── dev-fsfp-back.sh ├── dev-fsfp.sh ├── dev-link.sh ├── discard-dev-files.sh ├── generate_cookiecutter_config.py └── test.sh └── {{cookiecutter.project_slug}} ├── .dockerignore ├── .env ├── .github └── workflows │ └── actions.yml ├── .gitignore ├── README.md ├── backend ├── .dockerignore ├── .gitignore ├── app │ ├── .flake8 │ ├── .gitignore │ ├── .python-version │ ├── README.md │ ├── alembic.ini │ ├── app │ │ ├── __init__.py │ │ ├── __version__.py │ │ ├── api │ │ │ ├── __init__.py │ │ │ ├── api_v1 │ │ │ │ ├── __init__.py │ │ │ │ ├── api.py │ │ │ │ └── endpoints │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── login.py │ │ │ │ │ ├── proxy.py │ │ │ │ │ ├── services.py │ │ │ │ │ └── users.py │ │ │ ├── deps.py │ │ │ └── sockets.py │ │ ├── backend_pre_start.py │ │ ├── celeryworker_pre_start.py │ │ ├── core │ │ │ ├── __init__.py │ │ │ ├── celery_app.py │ │ │ ├── config.py │ │ │ └── security.py │ │ ├── crud │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── crud_token.py │ │ │ └── crud_user.py │ │ ├── db │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── base_class.py │ │ │ ├── init_db.py │ │ │ └── session.py │ │ ├── email-templates │ │ │ ├── build │ │ │ │ ├── confirm_email.html │ │ │ │ ├── magic_login.html │ │ │ │ ├── new_account.html │ │ │ │ ├── reset_password.html │ │ │ │ ├── test_email.html │ │ │ │ └── web_contact_email.html │ │ │ └── src │ │ │ │ ├── confirm_email.mjml │ │ │ │ ├── magic_login.mjml │ │ │ │ ├── new_account.mjml │ │ │ │ ├── reset_password.mjml │ │ │ │ ├── test_email.mjml │ │ │ │ └── web_contact_email.mjml │ │ ├── initial_data.py │ │ ├── main.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── token.py │ │ │ └── user.py │ │ ├── schema_types │ │ │ ├── __init__.py │ │ │ └── base_type.py │ │ ├── schemas │ │ │ ├── __init__.py │ │ │ ├── base_schema.py │ │ │ ├── emails.py │ │ │ ├── msg.py │ │ │ ├── token.py │ │ │ ├── totp.py │ │ │ └── user.py │ │ ├── tests │ │ │ ├── .gitignore │ │ │ ├── __init__.py │ │ │ ├── api │ │ │ │ ├── __init__.py │ │ │ │ └── api_v1 │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── test_login.py │ │ │ │ │ └── test_users.py │ │ │ ├── conftest.py │ │ │ ├── crud │ │ │ │ ├── __init__.py │ │ │ │ └── test_user.py │ │ │ └── utils │ │ │ │ ├── __init__.py │ │ │ │ ├── user.py │ │ │ │ └── utils.py │ │ ├── tests_pre_start.py │ │ ├── utilities │ │ │ ├── __init__.py │ │ │ └── email.py │ │ └── worker │ │ │ ├── __init__.py │ │ │ └── tests.py │ ├── mypy.ini │ ├── prestart.sh │ ├── pyproject.toml │ ├── scripts │ │ ├── format-imports.sh │ │ ├── format.sh │ │ ├── lint.sh │ │ ├── test-cov-html.sh │ │ └── test.sh │ ├── tests-start.sh │ └── worker-start.sh ├── backend.dockerfile └── celeryworker.dockerfile ├── cookiecutter-config-file.yml ├── docker-compose.override.yml ├── docker-compose.yml ├── frontend ├── .env.local ├── .eslintrc.json ├── .gitignore ├── Dockerfile ├── README.md ├── app │ ├── about │ │ └── page.tsx │ ├── assets │ │ └── css │ │ │ └── main.css │ ├── authentication │ │ └── page.tsx │ ├── blog │ │ ├── [id] │ │ │ └── page.tsx │ │ └── page.tsx │ ├── components │ │ ├── Footer.tsx │ │ ├── Navigation.tsx │ │ ├── Notification.tsx │ │ ├── alerts │ │ │ └── AlertsButton.tsx │ │ ├── authentication │ │ │ └── AuthenticationNavigation.tsx │ │ ├── moderation │ │ │ ├── CheckState.tsx │ │ │ ├── CheckToggle.tsx │ │ │ ├── CreateUser.tsx │ │ │ ├── ToggleActive.tsx │ │ │ ├── ToggleMod.tsx │ │ │ └── UserTable.tsx │ │ └── settings │ │ │ ├── Profile.tsx │ │ │ ├── Security.tsx │ │ │ └── ValidateEmailButton.tsx │ ├── contact │ │ └── page.tsx │ ├── content │ │ ├── about.md │ │ ├── authentication.md │ │ ├── blog │ │ │ ├── 20160708-theranos-and-elitism.md │ │ │ ├── 20160721-lament-for-the-auther.md │ │ │ └── 20170203-summer-of-99.md │ │ └── privacy.md │ ├── favicon.ico │ ├── layout.tsx │ ├── lib │ │ ├── api │ │ │ ├── auth.ts │ │ │ ├── core.ts │ │ │ ├── index.ts │ │ │ └── services.ts │ │ ├── hooks.ts │ │ ├── interfaces │ │ │ ├── index.ts │ │ │ ├── profile.ts │ │ │ └── utilities.ts │ │ ├── reduxProvider.tsx │ │ ├── slices │ │ │ ├── authSlice.ts │ │ │ ├── toastsSlice.ts │ │ │ └── tokensSlice.ts │ │ ├── storage.ts │ │ ├── store.ts │ │ └── utilities │ │ │ ├── generic.ts │ │ │ ├── index.ts │ │ │ ├── posts.ts │ │ │ ├── textual.ts │ │ │ └── totp.ts │ ├── login │ │ └── page.tsx │ ├── magic │ │ └── page.tsx │ ├── moderation │ │ └── page.tsx │ ├── page.tsx │ ├── recover-password │ │ └── page.tsx │ ├── reset-password │ │ └── page.tsx │ ├── settings │ │ └── page.tsx │ └── totp │ │ └── page.tsx ├── next-env.d.ts ├── next.config.js ├── package.json ├── postcss.config.mjs ├── public │ ├── next.svg │ └── vercel.svg ├── tailwind.config.ts └── tsconfig.json └── scripts ├── build-push.sh ├── build.sh ├── deploy.sh ├── test-local.sh └── test.sh /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | groups: 9 | actions: 10 | patterns: 11 | - "*" 12 | assignees: 13 | - "@mongodb/dbx-python" 14 | # Python 15 | - package-ecosystem: "pip" 16 | directory: "{{cookiecutter.project_slug}}/backend" 17 | schedule: 18 | interval: "weekly" 19 | assignees: 20 | - "@mongodb/dbx-python" 21 | # Node 22 | - package-ecosystem: "npm" 23 | directory: "{{cookiecutter.project_slug}}/frontend" 24 | schedule: 25 | interval: "weekly" 26 | assignees: 27 | - "@mongodb/dbx-python" -------------------------------------------------------------------------------- /.github/workflows/issue-manager.yml: -------------------------------------------------------------------------------- 1 | name: Issue Manager 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | issue_comment: 7 | types: 8 | - created 9 | - edited 10 | issues: 11 | types: 12 | - labeled 13 | 14 | jobs: 15 | issue-manager: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: tiangolo/issue-manager@0.2.0 19 | with: 20 | token: ${{ secrets.GITHUB_TOKEN }} 21 | config: > 22 | { 23 | "answered": { 24 | "users": ["tiangolo"], 25 | "delay": 864000, 26 | "message": "Assuming the original issue was solved, it will be automatically closed now. But feel free to add more comments or create new issues." 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | testing-project 3 | .mypy_cache 4 | # poetry.lock 5 | dev-link/ 6 | 7 | .DS_Store 8 | .idea/ 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | 3 | language: python 4 | 5 | install: 6 | - pip install cookiecutter 7 | 8 | services: 9 | - docker 10 | 11 | script: 12 | - bash ./scripts/test.sh 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Here are some short guidelines to guide you if you want to contribute to the development of the Full Stack FastAPI MongoDB project generator itself. 4 | 5 | After you clone the project, there are several scripts that can help during development. 6 | 7 | * `./scripts/dev-fsfp.sh`: 8 | 9 | Generate a new default project `dev-fsfp`. 10 | 11 | Call it from one level above the project directory. So, if the project is at `~/code/full-stack-fastapi-mongodb/`, call it from `~/code/`, like: 12 | 13 | ```console 14 | $ cd ~/code/ 15 | 16 | $ bash ./full-stack-fastapi-mongodb/scripts/dev-fsfp.sh 17 | ``` 18 | 19 | It will generate a new project with all the defaults at `~/code/dev-fsfp/`. 20 | 21 | You can go to that directory with a full new project, edit files and test things, for example: 22 | 23 | ```console 24 | $ cd ./dev-fsfp/ 25 | 26 | $ docker-compose up -d 27 | ``` 28 | 29 | It is outside of the project generator directory to let you add Git to it and compare versions and changes. 30 | 31 | * `./scripts/dev-fsfp-back.sh`: 32 | 33 | Move the changes from a project `dev-fsfp` back to the project generator. 34 | 35 | You would call it after calling `./scripts/dev-fsfp.sh` and adding some modifications to `dev-fsfp`. 36 | 37 | Call it from one level above the project directory. So, if the project is at `~/code/full-stack-fastapi-mongodb/`, call it from `~/code/`, like: 38 | 39 | ```console 40 | $ cd ~/code/ 41 | 42 | $ bash ./full-stack-fastapi-mongodb/scripts/dev-fsfp-back.sh 43 | ``` 44 | 45 | That will also contain all the generated files with the generated variables, but it will let you compare the changes in `dev-fsfp` and the source in the project generator with git, and see what to commit. 46 | 47 | * `./scripts/discard-dev-files.sh`: 48 | 49 | After using `./scripts/dev-fsfp-back.sh`, there will be a bunch of generated files with the variables for the generated project that you don't want to commit, like `README.md` and `.actions.yml`. 50 | 51 | To discard all those changes at once, run `discard-dev-files.sh` from the root of the project, e.g.: 52 | 53 | ```console 54 | $ cd ~/code/full-stack-fastapi-mongodb/ 55 | 56 | $ bash ./scripts/dev-fsfp-back.sh 57 | ``` 58 | 59 | * `./scripts/test.sh`: 60 | 61 | Run the tests. It creates a project `testing-project` *inside* of the project generator and runs its tests. 62 | 63 | Call it from the root of the project, e.g.: 64 | 65 | ```console 66 | $ cd ~/code/full-stack-fastapi-mongodb/ 67 | 68 | $ bash ./scripts/test.sh 69 | ``` 70 | 71 | * `./scripts/dev-link.sh`: 72 | 73 | Set up a local directory with links to the files for live development with the source files. 74 | 75 | This script generates a project `dev-link` *inside* the project generator, just to generate the `.env` and `./frontend/.env` files. 76 | 77 | Then it removes everything except those 2 files. 78 | 79 | Then it creates links for each of the source files, and adds those 2 files back. 80 | 81 | The end result is that you can go into the `dev-link` directory and develop locally with it as if it was a generated project, with all the variables set. But all the changes are actually done directly in the source files. 82 | 83 | This is probably a lot faster to iterate than using `./scripts/dev-fsfp.sh`. But it's tested only in Linux, it might not work in other systems. 84 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 MongoDB Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /THIRD-PARTY-NOTICES.txt: -------------------------------------------------------------------------------- 1 | MongoDB uses third-party libraries or other resources that may be 2 | distributed under licenses different than the MongoDB software. 3 | 4 | In the event that we accidentally failed to list a required notice, please 5 | bring it to our attention by creating a pull-request or filing a github 6 | issue with the full-stack-fastapi-mongodb project. 7 | 8 | The attached notices are provided for information only. 9 | 10 | For any licenses that require disclosure of source, sources are available at 11 | https://github.com/mongodb-labs/full-stack-fastapi-mongodb. 12 | 13 | 1) License notice for full-stack-fastapi-postgresql 14 | --------------------------------------------------- 15 | 16 | MIT License 17 | 18 | Copyright (c) 2019 Sebastián Ramírez 19 | 20 | Permission is hereby granted, free of charge, to any person obtaining a copy 21 | of this software and associated documentation files (the "Software"), to deal 22 | in the Software without restriction, including without limitation the rights 23 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 24 | copies of the Software, and to permit persons to whom the Software is 25 | furnished to do so, subject to the following conditions: 26 | 27 | The above copyright notice and this permission notice shall be included in all 28 | copies or substantial portions of the Software. 29 | 30 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 31 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 32 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 33 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 34 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 35 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 36 | SOFTWARE. 37 | -------------------------------------------------------------------------------- /cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_name": "Base Project", 3 | "project_slug": "{{ cookiecutter.project_name|lower|replace(' ', '-') }}", 4 | "domain_main": "{{cookiecutter.project_slug}}.com", 5 | "domain_staging": "stag.{{cookiecutter.domain_main}}", 6 | "domain_base_api_url": "http://{{cookiecutter.domain_main}}/api/v1", 7 | "domain_base_ws_url": "ws://{{cookiecutter.domain_main}}/api/v1", 8 | 9 | "docker_swarm_stack_name_main": "{{cookiecutter.domain_main|replace('.', '-')}}", 10 | "docker_swarm_stack_name_staging": "{{cookiecutter.domain_staging|replace('.', '-')}}", 11 | 12 | "secret_key": "changethis", 13 | "totp_secret_key": "changethis", 14 | "first_superuser": "admin@{{cookiecutter.domain_main}}", 15 | "first_superuser_password": "changethis", 16 | "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}}\"]", 17 | "smtp_tls": true, 18 | "smtp_port": "587", 19 | "smtp_host": "", 20 | "smtp_user": "", 21 | "smtp_password": "", 22 | "smtp_emails_from_email": "info@{{cookiecutter.domain_main}}", 23 | "smtp_emails_from_name": "Symona Adaro", 24 | "smtp_emails_to_email": "info@{{cookiecutter.domain_main}}", 25 | 26 | "mongodb_uri": "mongodb", 27 | "mongodb_database": "app", 28 | 29 | "traefik_constraint_tag": "{{cookiecutter.domain_main}}", 30 | "traefik_constraint_tag_staging": "{{cookiecutter.domain_staging}}", 31 | "traefik_public_constraint_tag": "traefik-public", 32 | 33 | "flower_auth": "admin:{{cookiecutter.first_superuser_password}}", 34 | 35 | "sentry_dsn": "", 36 | 37 | "docker_image_prefix": "", 38 | 39 | "docker_image_backend": "{{cookiecutter.docker_image_prefix}}backend", 40 | "docker_image_celeryworker": "{{cookiecutter.docker_image_prefix}}celeryworker", 41 | "docker_image_frontend": "{{cookiecutter.docker_image_prefix}}frontend", 42 | 43 | "_copy_without_render": [ 44 | "frontend/**/*.html", 45 | "frontend/**/*.tsx", 46 | "frontend/**/*.ico", 47 | "frontend/.next/*", 48 | "frontend/node_modules/*", 49 | "backend/app/app/email-templates/**" 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /hooks/post_gen_project.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | path: Path 5 | for path in Path(".").glob("**/*.sh"): 6 | data = path.read_bytes() 7 | lf_data = data.replace(b"\r\n", b"\n") 8 | path.write_bytes(lf_data) 9 | -------------------------------------------------------------------------------- /img/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb-labs/full-stack-fastapi-mongodb/ffbc2ca97388f7f90b4e24e808c26b67d5f783cf/img/dashboard.png -------------------------------------------------------------------------------- /img/docker_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb-labs/full-stack-fastapi-mongodb/ffbc2ca97388f7f90b4e24e808c26b67d5f783cf/img/docker_example.png -------------------------------------------------------------------------------- /img/landing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb-labs/full-stack-fastapi-mongodb/ffbc2ca97388f7f90b4e24e808c26b67d5f783cf/img/landing.png -------------------------------------------------------------------------------- /img/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb-labs/full-stack-fastapi-mongodb/ffbc2ca97388f7f90b4e24e808c26b67d5f783cf/img/login.png -------------------------------------------------------------------------------- /img/redoc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb-labs/full-stack-fastapi-mongodb/ffbc2ca97388f7f90b4e24e808c26b67d5f783cf/img/redoc.png -------------------------------------------------------------------------------- /img/totp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb-labs/full-stack-fastapi-mongodb/ffbc2ca97388f7f90b4e24e808c26b67d5f783cf/img/totp.png -------------------------------------------------------------------------------- /scripts/dev-fsfp-back.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Run this script from outside the project, to integrate a dev-fsfp project with changes and review modifications 4 | 5 | # Exit in case of error 6 | set -e 7 | 8 | if [ ! -d ./full-stack-fastapi-mongodb ] ; then 9 | echo "Run this script from outside the project, to integrate a sibling dev-fsfp project with changes and review modifications" 10 | exit 1 11 | fi 12 | 13 | if [ $(uname -s) = "Linux" ]; then 14 | echo "Remove __pycache__ files" 15 | sudo find ./dev-fsfp/ -type d -name __pycache__ -exec rm -r {} \+ 16 | fi 17 | 18 | rm -rf ./full-stack-fastapi-mongodb/\{\{cookiecutter.project_slug\}\}/* 19 | 20 | rsync -a --exclude=node_modules ./dev-fsfp/* ./full-stack-fastapi-mongodb/\{\{cookiecutter.project_slug\}\}/ 21 | 22 | rsync -a ./dev-fsfp/{.env,.gitignore,.github/workflows/actions.yml} ./full-stack-fastapi-mongodb/\{\{cookiecutter.project_slug\}\}/ 23 | -------------------------------------------------------------------------------- /scripts/dev-fsfp.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | if [ ! -d ./full-stack-fastapi-mongodb ] ; then 7 | echo "Run this script from outside the project, to generate a sibling dev-fsfp project with independent git" 8 | exit 1 9 | fi 10 | 11 | rm -rf ./dev-fsfp 12 | 13 | cookiecutter --no-input -f ./full-stack-fastapi-mongodb project_name="Dev FSFP" 14 | -------------------------------------------------------------------------------- /scripts/dev-link.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | # Run this from the root of the project to generate a dev-link project 7 | # It will contain a link to each of the files of the generator, except for 8 | # .env and frontend/.env, that will be the generated ones 9 | # This allows developing with a live stack while keeping the same source code 10 | # Without having to generate dev-fsfp and integrating back all the files 11 | 12 | rm -rf dev-link 13 | mkdir -p tmp-dev-link/frontend 14 | 15 | cookiecutter --no-input -f ./ project_name="Dev Link" 16 | 17 | mv ./dev-link/.env ./tmp-dev-link/ 18 | mv ./dev-link/frontend/.env ./tmp-dev-link/frontend/ 19 | 20 | rm -rf ./dev-link/ 21 | mkdir -p ./dev-link/ 22 | 23 | cd ./dev-link/ 24 | 25 | for f in ../\{\{cookiecutter.project_slug\}\}/* ; do 26 | ln -s "$f" ./ 27 | done 28 | 29 | cd .. 30 | 31 | mv ./tmp-dev-link/.env ./dev-link/ 32 | mv ./tmp-dev-link/frontend/.env ./dev-link/frontend/ 33 | 34 | rm -rf ./tmp-dev-link 35 | -------------------------------------------------------------------------------- /scripts/discard-dev-files.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | set -e 4 | 5 | rm -rf \{\{cookiecutter.project_slug\}\}/.git 6 | rm -rf \{\{cookiecutter.project_slug\}\}/backend/app/poetry.lock 7 | rm -rf \{\{cookiecutter.project_slug\}\}/frontend/node_modules 8 | rm -rf \{\{cookiecutter.project_slug\}\}/frontend/.nuxt 9 | git checkout \{\{cookiecutter.project_slug\}\}/README.md 10 | git checkout \{\{cookiecutter.project_slug\}\}/.github/workflows/actions.yml 11 | git checkout \{\{cookiecutter.project_slug\}\}/cookiecutter-config-file.yml 12 | git checkout \{\{cookiecutter.project_slug\}\}/.env 13 | git checkout \{\{cookiecutter.project_slug\}\}/frontend/.env 14 | -------------------------------------------------------------------------------- /scripts/generate_cookiecutter_config.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections import OrderedDict 3 | import oyaml as yaml 4 | from pathlib import Path 5 | cookie_path = Path('./cookiecutter.json') 6 | out_path = Path('./{{cookiecutter.project_slug}}/cookiecutter-config-file.yml') 7 | 8 | with open(cookie_path) as f: 9 | cookie_config = json.load(f) 10 | config_out = OrderedDict() 11 | 12 | for key, value in cookie_config.items(): 13 | if key.startswith('_'): 14 | config_out[key] = value 15 | else: 16 | config_out[key] = '{{ cookiecutter.' + key + ' }}' 17 | config_out['_template'] = './' 18 | 19 | with open(out_path, 'w') as out_f: 20 | out_f.write(yaml.dump({'default_context': config_out}, line_break=None, width=200)) 21 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | # Run this from the root of the project 7 | 8 | rm -rf ./testing-project 9 | 10 | cookiecutter --no-input -f ./ project_name="Testing Project" 11 | 12 | cd ./testing-project 13 | 14 | bash ./scripts/test.sh "$@" 15 | 16 | cd ../ 17 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/.dockerignore: -------------------------------------------------------------------------------- 1 | # Get rid of .venv when copying 2 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file 3 | */.venv 4 | */*/.venv -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/.env: -------------------------------------------------------------------------------- 1 | DOMAIN=localhost 2 | # DOMAIN=local.dockertoolbox.tiangolo.com 3 | # DOMAIN=localhost.tiangolo.com 4 | # DOMAIN=dev.{{cookiecutter.domain_main}} 5 | 6 | STACK_NAME={{cookiecutter.docker_swarm_stack_name_main}} 7 | 8 | TRAEFIK_PUBLIC_NETWORK=traefik-public 9 | TRAEFIK_TAG={{cookiecutter.traefik_constraint_tag}} 10 | TRAEFIK_PUBLIC_TAG={{cookiecutter.traefik_public_constraint_tag}} 11 | 12 | DOCKER_IMAGE_BACKEND={{cookiecutter.docker_image_backend}} 13 | DOCKER_IMAGE_CELERYWORKER={{cookiecutter.docker_image_celeryworker}} 14 | DOCKER_IMAGE_FRONTEND={{cookiecutter.docker_image_frontend}} 15 | 16 | # Backend 17 | BACKEND_APP_MODULE=app.main:app 18 | BACKEND_CORS_ORIGINS={{cookiecutter.backend_cors_origins}} 19 | BACKEND_PRE_START_PATH=/app/prestart.sh 20 | PROJECT_NAME={{cookiecutter.project_name}} 21 | SECRET_KEY={{cookiecutter.secret_key}} 22 | TOTP_SECRET_KEY={{cookiecutter.totp_secret_key}} 23 | FIRST_SUPERUSER={{cookiecutter.first_superuser}} 24 | FIRST_SUPERUSER_PASSWORD={{cookiecutter.first_superuser_password}} 25 | SMTP_TLS={{cookiecutter.smtp_tls}} 26 | SMTP_PORT={{cookiecutter.smtp_port}} 27 | SMTP_HOST={{cookiecutter.smtp_host}} 28 | SMTP_USER={{cookiecutter.smtp_user}} 29 | SMTP_PASSWORD={{cookiecutter.smtp_password}} 30 | EMAILS_FROM_EMAIL={{cookiecutter.smtp_emails_from_email}} 31 | EMAILS_FROM_NAME={{cookiecutter.smtp_emails_from_name}} 32 | EMAILS_TO_EMAIL={{cookiecutter.smtp_emails_to_email}} 33 | 34 | USERS_OPEN_REGISTRATION=True 35 | 36 | SENTRY_DSN={{cookiecutter.sentry_dsn}} 37 | 38 | # Flower 39 | FLOWER_BASIC_AUTH={{cookiecutter.flower_auth}} 40 | 41 | # Mongo 42 | MONGO_DATABASE_URI={{cookiecutter.mongodb_uri}} 43 | MONGO_DATABASE={{cookiecutter.mongodb_database}} -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/.github/workflows/actions.yml: -------------------------------------------------------------------------------- 1 | name: build and deploy template 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - production 7 | 8 | env: 9 | TRAEFIK_PUBLIC_NETWORK: traefik-public 10 | STACK_NAME: {{cookiecutter.docker_swarm_stack_name_main}} 11 | DOCKER_IMAGE_CELERYWORKER: {{cookiecutter.docker_image_celeryworker}} 12 | TRAEFIK_TAG: {{cookiecutter.traefik_constraint_tag}} 13 | TRAEFIK_PUBLIC_TAG: {{cookiecutter.traefik_public_constraint_tag}} 14 | DOCKER_IMAGE_BACKEND: {{cookiecutter.docker_image_backend}} 15 | DOCKER_IMAGE_FRONTEND: {{cookiecutter.docker_image_frontend}} 16 | PROJECT_NAME: {{cookiecutter.project_name}} 17 | DOMAIN: localhost 18 | SMTP_HOST: 19 | 20 | jobs: 21 | tests: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Check out code 25 | uses: actions/checkout@v4 26 | 27 | - name: Run Tests 28 | run: sh ./scripts/test.sh 29 | 30 | deploy-staging: 31 | if: github.ref == 'refs/heads/main' 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Check out code 35 | uses: actions/checkout@v4 36 | 37 | {% raw %} 38 | - name: Log in to Docker Registry 39 | uses: docker/login-action@v3 40 | with: 41 | username: ${{ secrets.DOCKERHUB_USERNAME }} 42 | password: ${{ secrets.DOCKERHUB_TOKEN }} 43 | {% endraw %} 44 | 45 | - name: Install docker-auto-labels 46 | run: pip install docker-auto-labels 47 | 48 | - name: Build Staging 49 | run: | 50 | DOMAIN={{cookiecutter.domain_staging}} \ 51 | TRAEFIK_TAG={{cookiecutter.traefik_constraint_tag_staging}} \ 52 | STACK_NAME={{cookiecutter.docker_swarm_stack_name_staging}} \ 53 | TAG=staging \ 54 | FRONTEND_ENV=staging \ 55 | sh ./scripts/build-push.sh 56 | 57 | # Uncomment to attempt deploying, need to valiate functionality 58 | # - name: Deploy Staging 59 | # run: | 60 | # DOMAIN={{cookiecutter.domain_staging}} \ 61 | # TRAEFIK_TAG={{cookiecutter.traefik_constraint_tag_staging}} \ 62 | # STACK_NAME={{cookiecutter.docker_swarm_stack_name_staging}} \ 63 | # TAG=staging \ 64 | # sh ./scripts/deploy.sh 65 | needs: tests 66 | 67 | deploy-prod: 68 | if: github.ref == 'refs/heads/production' 69 | runs-on: ubuntu-latest 70 | steps: 71 | - name: Check out code 72 | uses: actions/checkout@v4 73 | {% raw %} 74 | - name: Log in to Docker Registry 75 | uses: docker/login-action@v3 76 | with: 77 | username: ${{ secrets.DOCKERHUB_USERNAME }} 78 | password: ${{ secrets.DOCKERHUB_TOKEN }} 79 | {% endraw %} 80 | - name: Install docker-auto-labels 81 | run: pip install docker-auto-labels 82 | 83 | - name: Build Production 84 | run: | 85 | DOMAIN={{cookiecutter.domain_main}} \ 86 | TRAEFIK_TAG={{cookiecutter.traefik_constraint_tag}} \ 87 | STACK_NAME={{cookiecutter.docker_swarm_stack_name_main}} \ 88 | TAG=prod \ 89 | FRONTEND_ENV=production \ 90 | sh ./scripts/build-push.sh 91 | 92 | # Uncomment to attempt deploying, need to valiate functionality 93 | # - name: Deploy Production 94 | # run: | 95 | # DOMAIN={{cookiecutter.domain_main}} \ 96 | # TRAEFIK_TAG={{cookiecutter.traefik_constraint_tag}} \ 97 | # STACK_NAME={{cookiecutter.docker_swarm_stack_name_main}} \ 98 | # TAG=prod \ 99 | # sh ./scripts/deploy.sh 100 | needs: tests 101 | 102 | 103 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .mypy_cache 3 | docker-stack.yml 4 | *.txt 5 | .env 6 | *.code-workspace 7 | .s3cfg 8 | 9 | .DS_Store -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/.dockerignore: -------------------------------------------------------------------------------- 1 | # Get rid of .venv when copying 2 | */.venv 3 | */*/.venv -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | app.egg-info 3 | 4 | .DS_Store -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E302, E305, E203, E501, W503 3 | select = C,E,F,W,B,B950 4 | max-line-length = 120 5 | exclude = .git,__pycache__,__init__.py,.mypy_cache,.pytest_cache 6 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache 2 | .coverage 3 | htmlcov 4 | .venv 5 | .vscode 6 | *.py[co] 7 | *.egg 8 | *.egg-info 9 | *.ipynb 10 | *.code-workspace 11 | dist 12 | eggs 13 | parts 14 | bin 15 | var 16 | sdist 17 | develop-eggs 18 | .installed.cfg 19 | pip-log.txt 20 | .coverage 21 | .tox 22 | *.mo 23 | .s3cfg -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/.python-version: -------------------------------------------------------------------------------- 1 | 3.11.0 2 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | #truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to alembic/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat alembic/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | # Logging configuration 39 | [loggers] 40 | keys = root,sqlalchemy,alembic 41 | 42 | [handlers] 43 | keys = console 44 | 45 | [formatters] 46 | keys = generic 47 | 48 | [logger_root] 49 | level = WARN 50 | handlers = console 51 | qualname = 52 | 53 | [logger_sqlalchemy] 54 | level = WARN 55 | handlers = 56 | qualname = sqlalchemy.engine 57 | 58 | [logger_alembic] 59 | level = INFO 60 | handlers = 61 | qualname = alembic 62 | 63 | [handler_console] 64 | class = StreamHandler 65 | args = (sys.stderr,) 66 | level = NOTSET 67 | formatter = generic 68 | 69 | [formatter_generic] 70 | format = %(levelname)-5.5s [%(name)s] %(message)s 71 | datefmt = %H:%M:%S 72 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb-labs/full-stack-fastapi-mongodb/ffbc2ca97388f7f90b4e24e808c26b67d5f783cf/{{cookiecutter.project_slug}}/backend/app/app/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/__version__.py: -------------------------------------------------------------------------------- 1 | __version__ = "2023.11.10" 2 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb-labs/full-stack-fastapi-mongodb/ffbc2ca97388f7f90b4e24e808c26b67d5f783cf/{{cookiecutter.project_slug}}/backend/app/app/api/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb-labs/full-stack-fastapi-mongodb/ffbc2ca97388f7f90b4e24e808c26b67d5f783cf/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/api.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from app.api.api_v1.endpoints import ( 4 | login, 5 | users, 6 | proxy, 7 | ) 8 | 9 | api_router = APIRouter() 10 | api_router.include_router(login.router, prefix="/login", tags=["login"]) 11 | api_router.include_router(users.router, prefix="/users", tags=["users"]) 12 | api_router.include_router(proxy.router, prefix="/proxy", tags=["proxy"]) 13 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb-labs/full-stack-fastapi-mongodb/ffbc2ca97388f7f90b4e24e808c26b67d5f783cf/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/proxy.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from pydantic import AnyHttpUrl 3 | from fastapi import APIRouter, Depends, HTTPException, Request, Response 4 | import httpx 5 | 6 | from app import models 7 | from app.api import deps 8 | 9 | 10 | router = APIRouter() 11 | 12 | """ 13 | A proxy for the frontend client when hitting cors issues with axios requests. Adjust as required. This version has 14 | a user-login dependency to reduce the risk of leaking the server as a random proxy. 15 | """ 16 | 17 | 18 | @router.post("/{path:path}") 19 | async def proxy_post_request( 20 | *, 21 | path: AnyHttpUrl, 22 | request: Request, 23 | current_user: models.User = Depends(deps.get_current_active_user), 24 | ) -> Any: 25 | # https://www.starlette.io/requests/ 26 | # https://www.python-httpx.org/quickstart/ 27 | # https://github.com/tiangolo/fastapi/issues/1788#issuecomment-698698884 28 | # https://fastapi.tiangolo.com/tutorial/path-params/#__code_13 29 | try: 30 | data = await request.json() 31 | headers = { 32 | "Content-Type": request.headers.get("Content-Type"), 33 | "Authorization": request.headers.get("Authorization"), 34 | } 35 | async with httpx.AsyncClient() as client: 36 | proxy = await client.post(f"{path}", headers=headers, data=data) 37 | response = Response(content=proxy.content, status_code=proxy.status_code) 38 | return response 39 | except Exception as e: 40 | raise HTTPException(status_code=403, detail=str(e)) 41 | 42 | 43 | @router.get("/{path:path}") 44 | async def proxy_get_request( 45 | *, 46 | path: AnyHttpUrl, 47 | request: Request, 48 | current_user: models.User = Depends(deps.get_current_active_user), 49 | ) -> Any: 50 | try: 51 | headers = { 52 | "Content-Type": request.headers.get("Content-Type", "application/x-www-form-urlencoded"), 53 | "Authorization": request.headers.get("Authorization"), 54 | } 55 | async with httpx.AsyncClient() as client: 56 | proxy = await client.get(f"{path}", headers=headers) 57 | response = Response(content=proxy.content, status_code=proxy.status_code) 58 | return response 59 | except Exception as e: 60 | raise HTTPException(status_code=403, detail=str(e)) 61 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/services.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi import APIRouter 4 | 5 | from app import schemas 6 | from app.utilities import send_web_contact_email 7 | from app.schemas import EmailContent 8 | 9 | router = APIRouter() 10 | 11 | 12 | @router.post("/contact", response_model=schemas.Msg, status_code=201) 13 | def send_email(*, data: EmailContent) -> Any: 14 | """ 15 | Standard app contact us. 16 | """ 17 | send_web_contact_email(data=data) 18 | return {"msg": "Web contact email sent"} 19 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/sockets.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from fastapi import WebSocket 3 | from starlette.websockets import WebSocketDisconnect 4 | from websockets.exceptions import ConnectionClosedError 5 | 6 | 7 | async def send_response(*, websocket: WebSocket, response: dict): 8 | try: 9 | await websocket.send_json(response) 10 | return True 11 | except (WebSocketDisconnect, ConnectionClosedError): 12 | return False 13 | 14 | 15 | async def receive_request(*, websocket: WebSocket) -> dict: 16 | try: 17 | return await websocket.receive_json() 18 | except (WebSocketDisconnect, ConnectionClosedError): 19 | return {} 20 | 21 | 22 | def sanitize_data_request(data: any) -> any: 23 | # Putting here for want of a better place 24 | if isinstance(data, (list, tuple, set)): 25 | return type(data)(sanitize_data_request(x) for x in data if x or isinstance(x, bool)) 26 | elif isinstance(data, dict): 27 | return type(data)( 28 | (sanitize_data_request(k), sanitize_data_request(v)) 29 | for k, v in data.items() 30 | if k and v or isinstance(v, bool) 31 | ) 32 | else: 33 | return data 34 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/backend_pre_start.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed 5 | 6 | from app.db.session import ping 7 | 8 | logging.basicConfig(level=logging.INFO) 9 | logger = logging.getLogger(__name__) 10 | 11 | max_tries = 60 * 5 # 5 minutes 12 | wait_seconds = 1 13 | 14 | 15 | @retry( 16 | stop=stop_after_attempt(max_tries), 17 | wait=wait_fixed(wait_seconds), 18 | before=before_log(logger, logging.INFO), 19 | after=after_log(logger, logging.WARN), 20 | ) 21 | async def init() -> None: 22 | try: 23 | await ping() 24 | except Exception as e: 25 | logger.error(e) 26 | raise e 27 | 28 | 29 | async def main() -> None: 30 | logger.info("Initializing service") 31 | await init() 32 | logger.info("Service finished initializing") 33 | 34 | 35 | if __name__ == "__main__": 36 | asyncio.run(main()) 37 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/celeryworker_pre_start.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed 5 | 6 | from app.db.session import ping 7 | 8 | logging.basicConfig(level=logging.INFO) 9 | logger = logging.getLogger(__name__) 10 | 11 | max_tries = 60 * 5 # 5 minutes 12 | wait_seconds = 1 13 | 14 | 15 | @retry( 16 | stop=stop_after_attempt(max_tries), 17 | wait=wait_fixed(wait_seconds), 18 | before=before_log(logger, logging.INFO), 19 | after=after_log(logger, logging.WARN), 20 | ) 21 | async def init() -> None: 22 | try: 23 | # Try to create session to check if DB is awake 24 | await ping() 25 | except Exception as e: 26 | logger.error(e) 27 | raise e 28 | 29 | 30 | async def main() -> None: 31 | logger.info("Initializing service") 32 | await init() 33 | logger.info("Service finished initializing") 34 | 35 | 36 | if __name__ == "__main__": 37 | asyncio.run(main()) 38 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb-labs/full-stack-fastapi-mongodb/ffbc2ca97388f7f90b4e24e808c26b67d5f783cf/{{cookiecutter.project_slug}}/backend/app/app/core/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/core/celery_app.py: -------------------------------------------------------------------------------- 1 | from celery import Celery 2 | 3 | celery_app = Celery("worker", broker="amqp://guest@queue//") 4 | 5 | celery_app.conf.task_routes = {"app.worker.*": "main-queue"} 6 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/core/config.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | from typing import Any, Dict, List, Union, Annotated 3 | 4 | from pydantic import AnyHttpUrl, EmailStr, HttpUrl, field_validator, BeforeValidator 5 | from pydantic_core.core_schema import ValidationInfo 6 | from pydantic_settings import BaseSettings 7 | 8 | def parse_cors(v: Any) -> list[str] | str: 9 | if isinstance(v, str) and not v.startswith("["): 10 | return [i.strip() for i in v.split(",")] 11 | elif isinstance(v, list | str): 12 | return v 13 | raise ValueError(v) 14 | 15 | class Settings(BaseSettings): 16 | API_V1_STR: str = "/api/v1" 17 | SECRET_KEY: str = secrets.token_urlsafe(32) 18 | TOTP_SECRET_KEY: str = secrets.token_urlsafe(32) 19 | # 60 minutes * 24 hours * 8 days = 8 days 20 | ACCESS_TOKEN_EXPIRE_SECONDS: int = 60 * 30 21 | REFRESH_TOKEN_EXPIRE_SECONDS: int = 60 * 60 * 24 * 30 22 | JWT_ALGO: str = "HS512" 23 | TOTP_ALGO: str = "SHA-1" 24 | SERVER_NAME: str 25 | SERVER_HOST: AnyHttpUrl 26 | SERVER_BOT: str = "Symona" 27 | # BACKEND_CORS_ORIGINS is a JSON-formatted list of origins 28 | # e.g: '["http://localhost", "http://localhost:4200", "http://localhost:3000", \ 29 | # "http://localhost:8080", "http://local.dockertoolbox.tiangolo.com"]' 30 | BACKEND_CORS_ORIGINS: Annotated[ 31 | list[AnyHttpUrl] | str, BeforeValidator(parse_cors) 32 | ] = [] 33 | 34 | PROJECT_NAME: str 35 | SENTRY_DSN: HttpUrl | None = None 36 | 37 | @field_validator("SENTRY_DSN", mode="before") 38 | def sentry_dsn_can_be_blank(cls, v: str) -> str | None: 39 | if isinstance(v, str) and len(v) == 0: 40 | return None 41 | return v 42 | 43 | # GENERAL SETTINGS 44 | 45 | MULTI_MAX: int = 20 46 | 47 | # COMPONENT SETTINGS 48 | MONGO_DATABASE: str 49 | MONGO_DATABASE_URI: str 50 | 51 | SMTP_TLS: bool = True 52 | SMTP_PORT: int = 587 53 | SMTP_HOST: str | None = None 54 | SMTP_USER: str | None = None 55 | SMTP_PASSWORD: str | None = None 56 | EMAILS_FROM_EMAIL: EmailStr | None = None 57 | EMAILS_FROM_NAME: str | None = None 58 | EMAILS_TO_EMAIL: EmailStr | None = None 59 | 60 | @field_validator("EMAILS_FROM_NAME") 61 | def get_project_name(cls, v: str | None, info: ValidationInfo) -> str: 62 | if not v: 63 | return info.data["PROJECT_NAME"] 64 | return v 65 | 66 | EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48 67 | EMAIL_TEMPLATES_DIR: str = "/app/app/email-templates/build" 68 | EMAILS_ENABLED: bool = False 69 | 70 | @field_validator("EMAILS_ENABLED", mode="before") 71 | def get_emails_enabled(cls, v: bool, info: ValidationInfo) -> bool: 72 | return bool(info.data.get("SMTP_HOST") and info.data.get("SMTP_PORT") and info.data.get("EMAILS_FROM_EMAIL")) 73 | 74 | EMAIL_TEST_USER: EmailStr = "test@example.com" # type: ignore 75 | FIRST_SUPERUSER: EmailStr 76 | FIRST_SUPERUSER_PASSWORD: str 77 | USERS_OPEN_REGISTRATION: bool = True 78 | 79 | 80 | settings = Settings() 81 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/core/security.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Any, Union 3 | 4 | from jose import jwt 5 | from passlib.context import CryptContext 6 | from passlib.totp import TOTP 7 | from passlib.exc import TokenError, MalformedTokenError 8 | import uuid 9 | 10 | from app.core.config import settings 11 | from app.schemas import NewTOTP 12 | 13 | """ 14 | https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/Authentication_Cheat_Sheet.md 15 | https://passlib.readthedocs.io/en/stable/lib/passlib.hash.argon2.html 16 | https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/Password_Storage_Cheat_Sheet.md 17 | https://blog.cloudflare.com/ensuring-randomness-with-linuxs-random-number-generator/ 18 | https://passlib.readthedocs.io/en/stable/lib/passlib.pwd.html 19 | Specifies minimum criteria: 20 | - Use Argon2id with a minimum configuration of 15 MiB of memory, an iteration count of 2, and 1 degree of parallelism. 21 | - Passwords shorter than 8 characters are considered to be weak (NIST SP800-63B). 22 | - Maximum password length of 64 prevents long password Denial of Service attacks. 23 | - Do not silently truncate passwords. 24 | - Allow usage of all characters including unicode and whitespace. 25 | """ 26 | pwd_context = CryptContext( 27 | schemes=["argon2", "bcrypt"], deprecated="auto" 28 | ) # current defaults: $argon2id$v=19$m=65536,t=3,p=4, "bcrypt" is deprecated 29 | totp_factory = TOTP.using(secrets={"1": settings.TOTP_SECRET_KEY}, issuer=settings.SERVER_NAME, alg=settings.TOTP_ALGO) 30 | 31 | 32 | def create_access_token(*, subject: Union[str, Any], expires_delta: timedelta = None, force_totp: bool = False) -> str: 33 | if expires_delta: 34 | expire = datetime.utcnow() + expires_delta 35 | else: 36 | expire = datetime.utcnow() + timedelta(seconds=settings.ACCESS_TOKEN_EXPIRE_SECONDS) 37 | to_encode = {"exp": expire, "sub": str(subject), "totp": force_totp} 38 | encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGO) 39 | return encoded_jwt 40 | 41 | 42 | def create_refresh_token(*, subject: Union[str, Any], expires_delta: timedelta = None) -> str: 43 | if expires_delta: 44 | expire = datetime.utcnow() + expires_delta 45 | else: 46 | expire = datetime.utcnow() + timedelta(seconds=settings.REFRESH_TOKEN_EXPIRE_SECONDS) 47 | to_encode = {"exp": expire, "sub": str(subject), "refresh": True} 48 | encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGO) 49 | return encoded_jwt 50 | 51 | 52 | def create_magic_tokens(*, subject: Union[str, Any], expires_delta: timedelta = None) -> list[str]: 53 | if expires_delta: 54 | expire = datetime.utcnow() + expires_delta 55 | else: 56 | expire = datetime.utcnow() + timedelta(seconds=settings.ACCESS_TOKEN_EXPIRE_SECONDS) 57 | fingerprint = str(uuid.uuid4()) 58 | magic_tokens = [] 59 | # First sub is the user.id, to be emailed. Second is the disposable id. 60 | for sub in [subject, uuid.uuid4()]: 61 | to_encode = {"exp": expire, "sub": str(sub), "fingerprint": fingerprint} 62 | encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGO) 63 | magic_tokens.append(encoded_jwt) 64 | return magic_tokens 65 | 66 | 67 | def create_new_totp(*, label: str, uri: str | None = None) -> NewTOTP: 68 | if not uri: 69 | totp = totp_factory.new() 70 | else: 71 | totp = totp_factory.from_source(uri) 72 | return NewTOTP( 73 | **{ 74 | "secret": totp.to_json(), 75 | "key": totp.pretty_key(), 76 | "uri": totp.to_uri(issuer=settings.SERVER_NAME, label=label), 77 | } 78 | ) 79 | 80 | 81 | def verify_totp(*, token: str, secret: str, last_counter: int = None) -> Union[str, bool]: 82 | """ 83 | token: from user 84 | secret: totp security string from user in db 85 | last_counter: int from user in db (may be None) 86 | """ 87 | try: 88 | match = totp_factory.verify(token, secret, last_counter=last_counter) 89 | except (MalformedTokenError, TokenError): 90 | return False 91 | else: 92 | return match.counter 93 | 94 | 95 | def verify_password(*, plain_password: str, hashed_password: str) -> bool: 96 | return pwd_context.verify(plain_password, hashed_password) 97 | 98 | 99 | def get_password_hash(password: str) -> str: 100 | return pwd_context.hash(password) 101 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py: -------------------------------------------------------------------------------- 1 | from .crud_user import user 2 | from .crud_token import token 3 | 4 | 5 | # For a new basic set of CRUD operations you could just do 6 | 7 | # from .base import CRUDBase 8 | # from app.models.item import Item 9 | # from app.schemas.item import ItemCreate, ItemUpdate 10 | 11 | # item = CRUDBase[Item, ItemCreate, ItemUpdate](Item) 12 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/crud/base.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Generic, Type, TypeVar, Union 2 | 3 | from fastapi.encoders import jsonable_encoder 4 | from pydantic import BaseModel 5 | from motor.core import AgnosticDatabase 6 | from odmantic import AIOEngine 7 | 8 | from app.db.base_class import Base 9 | from app.core.config import settings 10 | from app.db.session import get_engine 11 | 12 | ModelType = TypeVar("ModelType", bound=Base) 13 | CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) 14 | UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) 15 | 16 | 17 | class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): 18 | def __init__(self, model: Type[ModelType]): 19 | """ 20 | CRUD object with default methods to Create, Read, Update, Delete (CRUD). 21 | 22 | **Parameters** 23 | 24 | * `model`: A SQLAlchemy model class 25 | * `schema`: A Pydantic model (schema) class 26 | """ 27 | self.model = model 28 | self.engine: AIOEngine = get_engine() 29 | 30 | async def get(self, db: AgnosticDatabase, id: Any) -> ModelType | None: 31 | return await self.engine.find_one(self.model, self.model.id == id) 32 | 33 | async def get_multi(self, db: AgnosticDatabase, *, page: int = 0, page_break: bool = False) -> list[ModelType]: # noqa 34 | offset = {"skip": page * settings.MULTI_MAX, "limit": settings.MULTI_MAX} if page_break else {} # noqa 35 | return await self.engine.find(self.model, **offset) 36 | 37 | async def create(self, db: AgnosticDatabase, *, obj_in: CreateSchemaType) -> ModelType: # noqa 38 | obj_in_data = jsonable_encoder(obj_in) 39 | db_obj = self.model(**obj_in_data) # type: ignore 40 | return await self.engine.save(db_obj) 41 | 42 | async def update( 43 | self, db: AgnosticDatabase, *, db_obj: ModelType, obj_in: Union[UpdateSchemaType, Dict[str, Any]] # noqa 44 | ) -> ModelType: 45 | obj_data = jsonable_encoder(db_obj) 46 | if isinstance(obj_in, dict): 47 | update_data = obj_in 48 | else: 49 | update_data = obj_in.model_dump(exclude_unset=True) 50 | for field in obj_data: 51 | if field in update_data: 52 | setattr(db_obj, field, update_data[field]) 53 | # TODO: Check if this saves changes with the setattr calls 54 | await self.engine.save(db_obj) 55 | return db_obj 56 | 57 | async def remove(self, db: AgnosticDatabase, *, id: int) -> ModelType: 58 | obj = await self.model.get(id) 59 | if obj: 60 | await self.engine.delete(obj) 61 | return obj 62 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/crud/crud_token.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from motor.core import AgnosticDatabase 3 | 4 | from app.crud.base import CRUDBase 5 | from app.models import User, Token 6 | from app.schemas import RefreshTokenCreate, RefreshTokenUpdate 7 | from app.core.config import settings 8 | 9 | 10 | class CRUDToken(CRUDBase[Token, RefreshTokenCreate, RefreshTokenUpdate]): 11 | # Everything is user-dependent 12 | async def create(self, db: AgnosticDatabase, *, obj_in: str, user_obj: User) -> Token: 13 | db_obj = await self.engine.find_one(self.model, self.model.token == obj_in) 14 | if db_obj: 15 | if db_obj.authenticates_id != user_obj.id: 16 | raise ValueError("Token mismatch between key and user.") 17 | return db_obj 18 | else: 19 | new_token = self.model(token=obj_in, authenticates_id=user_obj) 20 | user_obj.refresh_tokens.append(new_token.id) 21 | await self.engine.save_all([new_token, user_obj]) 22 | return new_token 23 | 24 | async def get(self, *, user: User, token: str) -> Token: 25 | return await self.engine.find_one(User, ((User.id == user.id) & (User.refresh_tokens == token))) 26 | 27 | async def get_multi(self, *, user: User, page: int = 0, page_break: bool = False) -> list[Token]: 28 | offset = {"skip": page * settings.MULTI_MAX, "limit": settings.MULTI_MAX} if page_break else {} 29 | return await self.engine.find(User, (User.refresh_tokens.in_([user.refresh_tokens])), **offset) 30 | 31 | async def remove(self, db: AgnosticDatabase, *, db_obj: Token) -> None: 32 | users = [] 33 | async for user in self.engine.find(User, User.refresh_tokens.in_([db_obj.id])): 34 | user.refresh_tokens.remove(db_obj.id) 35 | users.append(user) 36 | await self.engine.save(users) 37 | await self.engine.delete(db_obj) 38 | 39 | 40 | token = CRUDToken(Token) 41 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/crud/crud_user.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Union 2 | 3 | from motor.core import AgnosticDatabase 4 | 5 | from app.core.security import get_password_hash, verify_password 6 | from app.crud.base import CRUDBase 7 | from app.models.user import User 8 | from app.schemas.user import UserCreate, UserInDB, UserUpdate 9 | from app.schemas.totp import NewTOTP 10 | 11 | 12 | # ODM, Schema, Schema 13 | class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]): 14 | async def get_by_email(self, db: AgnosticDatabase, *, email: str) -> User | None: # noqa 15 | return await self.engine.find_one(User, User.email == email) 16 | 17 | async def create(self, db: AgnosticDatabase, *, obj_in: UserCreate) -> User: # noqa 18 | # TODO: Figure out what happens when you have a unique key like 'email' 19 | user = { 20 | **obj_in.model_dump(), 21 | "email": obj_in.email, 22 | "hashed_password": get_password_hash(obj_in.password) if obj_in.password is not None else None, # noqa 23 | "full_name": obj_in.full_name, 24 | "is_superuser": obj_in.is_superuser, 25 | } 26 | 27 | return await self.engine.save(User(**user)) 28 | 29 | async def update(self, db: AgnosticDatabase, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]]) -> User: # noqa 30 | if isinstance(obj_in, dict): 31 | update_data = obj_in 32 | else: 33 | update_data = obj_in.model_dump(exclude_unset=True) 34 | if update_data.get("password"): 35 | hashed_password = get_password_hash(update_data["password"]) 36 | del update_data["password"] 37 | update_data["hashed_password"] = hashed_password 38 | if update_data.get("email") and db_obj.email != update_data["email"]: 39 | update_data["email_validated"] = False 40 | return await super().update(db, db_obj=db_obj, obj_in=update_data) 41 | 42 | async def authenticate(self, db: AgnosticDatabase, *, email: str, password: str) -> User | None: # noqa 43 | user = await self.get_by_email(db, email=email) 44 | if not user: 45 | return None 46 | if not verify_password(plain_password=password, hashed_password=user.hashed_password): # noqa 47 | return None 48 | return user 49 | 50 | async def validate_email(self, db: AgnosticDatabase, *, db_obj: User) -> User: # noqa 51 | obj_in = UserUpdate(**UserInDB.model_validate(db_obj).model_dump()) 52 | obj_in.email_validated = True 53 | return await self.update(db=db, db_obj=db_obj, obj_in=obj_in) 54 | 55 | async def activate_totp(self, db: AgnosticDatabase, *, db_obj: User, totp_in: NewTOTP) -> User: # noqa 56 | obj_in = UserUpdate(**UserInDB.model_validate(db_obj).model_dump()) 57 | obj_in = obj_in.model_dump(exclude_unset=True) 58 | obj_in["totp_secret"] = totp_in.secret 59 | return await self.update(db=db, db_obj=db_obj, obj_in=obj_in) 60 | 61 | async def deactivate_totp(self, db: AgnosticDatabase, *, db_obj: User) -> User: # noqa 62 | obj_in = UserUpdate(**UserInDB.model_validate(db_obj).model_dump()) 63 | obj_in = obj_in.model_dump(exclude_unset=True) 64 | obj_in["totp_secret"] = None 65 | obj_in["totp_counter"] = None 66 | return await self.update(db=db, db_obj=db_obj, obj_in=obj_in) 67 | 68 | async def update_totp_counter(self, db: AgnosticDatabase, *, db_obj: User, new_counter: int) -> User: # noqa 69 | obj_in = UserUpdate(**UserInDB.model_validate(db_obj).model_dump()) 70 | obj_in = obj_in.model_dump(exclude_unset=True) 71 | obj_in["totp_counter"] = new_counter 72 | return await self.update(db=db, db_obj=db_obj, obj_in=obj_in) 73 | 74 | async def toggle_user_state(self, db: AgnosticDatabase, *, obj_in: Union[UserUpdate, Dict[str, Any]]) -> User: # noqa 75 | db_obj = await self.get_by_email(db, email=obj_in.email) 76 | if not db_obj: 77 | return None 78 | return await self.update(db=db, db_obj=db_obj, obj_in=obj_in) 79 | 80 | @staticmethod 81 | def has_password(user: User) -> bool: 82 | return user.hashed_password is not None 83 | 84 | @staticmethod 85 | def is_active(user: User) -> bool: 86 | return user.is_active 87 | 88 | @staticmethod 89 | def is_superuser(user: User) -> bool: 90 | return user.is_superuser 91 | 92 | @staticmethod 93 | def is_email_validated(user: User) -> bool: 94 | return user.email_validated 95 | 96 | 97 | user = CRUDUser(User) 98 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb-labs/full-stack-fastapi-mongodb/ffbc2ca97388f7f90b4e24e808c26b67d5f783cf/{{cookiecutter.project_slug}}/backend/app/app/db/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/db/base.py: -------------------------------------------------------------------------------- 1 | from app.db.base_class import Base # noqa 2 | from app.models.user import User # noqa 3 | from app.models.token import Token # noqa 4 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/db/base_class.py: -------------------------------------------------------------------------------- 1 | from odmantic import Model 2 | 3 | Base = Model 4 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/db/init_db.py: -------------------------------------------------------------------------------- 1 | from pymongo.database import Database 2 | 3 | from app import crud, schemas 4 | from app.core.config import settings 5 | 6 | 7 | async def init_db(db: Database) -> None: 8 | user = await crud.user.get_by_email(db, email=settings.FIRST_SUPERUSER) 9 | if not user: 10 | # Create user auth 11 | user_in = schemas.UserCreate( 12 | email=settings.FIRST_SUPERUSER, 13 | password=settings.FIRST_SUPERUSER_PASSWORD, 14 | is_superuser=True, 15 | full_name=settings.FIRST_SUPERUSER, 16 | ) 17 | user = await crud.user.create(db, obj_in=user_in) # noqa: F841 18 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/db/session.py: -------------------------------------------------------------------------------- 1 | from app.core.config import settings 2 | from app.__version__ import __version__ 3 | from motor import motor_asyncio, core 4 | from odmantic import AIOEngine 5 | from pymongo.driver_info import DriverInfo 6 | 7 | DRIVER_INFO = DriverInfo(name="full-stack-fastapi-mongodb", version=__version__) 8 | 9 | 10 | class _MongoClientSingleton: 11 | mongo_client: motor_asyncio.AsyncIOMotorClient | None 12 | engine: AIOEngine 13 | 14 | def __new__(cls): 15 | if not hasattr(cls, "instance"): 16 | cls.instance = super(_MongoClientSingleton, cls).__new__(cls) 17 | cls.instance.mongo_client = motor_asyncio.AsyncIOMotorClient( 18 | settings.MONGO_DATABASE_URI, driver=DRIVER_INFO 19 | ) 20 | cls.instance.engine = AIOEngine(client=cls.instance.mongo_client, database=settings.MONGO_DATABASE) 21 | return cls.instance 22 | 23 | 24 | def MongoDatabase() -> core.AgnosticDatabase: 25 | return _MongoClientSingleton().mongo_client[settings.MONGO_DATABASE] 26 | 27 | 28 | def get_engine() -> AIOEngine: 29 | return _MongoClientSingleton().engine 30 | 31 | 32 | async def ping(): 33 | await MongoDatabase().command("ping") 34 | 35 | 36 | __all__ = ["MongoDatabase", "ping"] 37 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/email-templates/build/test_email.html: -------------------------------------------------------------------------------- 1 |

{{ project_name }}
Test email for: {{ email }}
-------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/email-templates/build/web_contact_email.html: -------------------------------------------------------------------------------- 1 |

Email from: {{ email }}
{{ content }}
-------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/email-templates/src/confirm_email.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | Confirm your email address 14 | Hi, 17 | You'll need to validate this email address before you can offer your 19 | work. All you need to do is click this button. 21 | 27 | Confirm your email 28 | 29 | Or open the following link: 32 | {{ link }} 35 | If you have no idea what this is about, then don't worry. You can 37 | safely ignore and delete this email. 39 | For any concerns or queries, just email me. 42 | {{ server_bot }} @ {{ server_name }} 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/email-templates/src/magic_login.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | Log in to {{ project_name }} 14 | Welcome! Login to your account by clicking the button below within the next {{ valid_minutes }} minutes: 17 | 23 | Login 24 | 25 | Or copy the following link and paste it in your browser: 28 | {{ link }} 31 | 32 | Make sure you use this code on the same device and in the same browser where you made this request or it won't work. 35 | 36 | For any concerns or queries, especially if you didn't make this request or feel you received it by mistake, just email me. 39 | {{ server_bot }} @ {{ server_name }} 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/email-templates/src/new_account.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | {{ project_name }} - New Account 12 | Hi, 15 | You have a new account: 16 | Username: {{ username }} 17 | Password: {{ password }} 18 | 24 | Go to Dashboard 25 | 26 | If you have no idea what this is about, then don't worry. You can 28 | safely ignore and delete this email. 30 | For any concerns or queries, just email me. 33 | {{ server_bot }} @ {{ server_name }} 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/email-templates/src/reset_password.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | {{ project_name }} - Password Recovery 14 | We received a request to recover the password for user {{ username }} 16 | with email {{ email }} 18 | Reset your password by clicking the button below: 21 | 27 | Reset Password 28 | 29 | Or open the following link: 32 | {{ link }} 35 | 36 | The reset password link / button will expire in {{ valid_hours }} 38 | hours. 40 | If you didn't request a password recovery you can disregard this 42 | email. 44 | For any concerns or queries, just email me. 47 | {{ server_bot }} @ {{ server_name }} 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/email-templates/src/test_email.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ project_name }} 7 | Test email for: {{ email }} 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/email-templates/src/web_contact_email.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | Email from: {{ email }} 14 | {{ content }} 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/initial_data.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from app.db.init_db import init_db 5 | from app.db.session import MongoDatabase 6 | 7 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed 8 | 9 | logging.basicConfig(level=logging.INFO) 10 | logger = logging.getLogger(__name__) 11 | 12 | max_tries = 60 * 5 # 5 minutes 13 | wait_seconds = 1 14 | 15 | 16 | @retry( 17 | stop=stop_after_attempt(max_tries), 18 | wait=wait_fixed(wait_seconds), 19 | before=before_log(logger, logging.INFO), 20 | after=after_log(logger, logging.WARN), 21 | ) 22 | async def populate_db() -> None: 23 | await init_db(MongoDatabase()) 24 | # Place any code after this line to add any db population steps 25 | 26 | 27 | async def main() -> None: 28 | logger.info("Creating initial data") 29 | await populate_db() 30 | logger.info("Initial data created") 31 | 32 | 33 | if __name__ == "__main__": 34 | asyncio.run(main()) 35 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from starlette.middleware.cors import CORSMiddleware 3 | from contextlib import asynccontextmanager 4 | 5 | from app.api.api_v1.api import api_router 6 | from app.core.config import settings 7 | 8 | 9 | @asynccontextmanager 10 | async def app_init(app: FastAPI): 11 | app.include_router(api_router, prefix=settings.API_V1_STR) 12 | yield 13 | 14 | 15 | app = FastAPI( 16 | title=settings.PROJECT_NAME, 17 | openapi_url=f"{settings.API_V1_STR}/openapi.json", 18 | lifespan=app_init, 19 | ) 20 | 21 | # Set all CORS enabled origins 22 | if settings.BACKEND_CORS_ORIGINS: 23 | app.add_middleware( 24 | CORSMiddleware, 25 | # Trailing slash causes CORS failures from these supported domains 26 | allow_origins=[str(origin).strip("/") for origin in settings.BACKEND_CORS_ORIGINS], # noqa 27 | allow_credentials=True, 28 | allow_methods=["*"], 29 | allow_headers=["*"], 30 | ) 31 | 32 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import User 2 | from .token import Token 3 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/models/token.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from odmantic import Reference 4 | 5 | from app.db.base_class import Base 6 | 7 | from .user import User # noqa: F401 8 | 9 | 10 | # Consider reworking to consolidate information to a userId. This may not work well 11 | class Token(Base): 12 | token: str 13 | authenticates_id: User = Reference() 14 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/models/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING, Any, Optional 3 | from datetime import datetime 4 | from pydantic import EmailStr 5 | from odmantic import ObjectId, Field 6 | 7 | from app.db.base_class import Base 8 | 9 | if TYPE_CHECKING: 10 | from . import Token # noqa: F401 11 | 12 | 13 | def datetime_now_sec(): 14 | return datetime.now().replace(microsecond=0) 15 | 16 | 17 | class User(Base): 18 | created: datetime = Field(default_factory=datetime_now_sec) 19 | modified: datetime = Field(default_factory=datetime_now_sec) 20 | full_name: str = Field(default="") 21 | email: EmailStr 22 | hashed_password: Any = Field(default=None) 23 | totp_secret: Any = Field(default=None) 24 | totp_counter: Optional[int] = Field(default=None) 25 | email_validated: bool = Field(default=False) 26 | is_active: bool = Field(default=False) 27 | is_superuser: bool = Field(default=False) 28 | refresh_tokens: list[ObjectId] = Field(default_factory=list) 29 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/schema_types/__init__.py: -------------------------------------------------------------------------------- 1 | from .base_type import BaseEnum 2 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/schema_types/base_type.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class BaseEnum(str, Enum): 5 | # noinspection PyMethodParameters 6 | # cf https://gitter.im/tiangolo/fastapi?at=5d775f4050508949d30b6eec 7 | def _generate_next_value_(name, start, count, last_values) -> str: # type: ignore 8 | """ 9 | Uses the name as the automatic value, rather than an integer 10 | 11 | See https://docs.python.org/3/library/enum.html#using-automatic-values for reference 12 | """ 13 | return name 14 | 15 | @classmethod 16 | def as_dict(cls): 17 | member_dict = {role: member.value for role, member in cls.__members__.items()} 18 | return member_dict 19 | 20 | @classmethod 21 | def _missing_(cls, value): 22 | # https://stackoverflow.com/a/68311691/295606 23 | for member in cls: 24 | if member.value.upper() == value.upper(): 25 | return member 26 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .base_schema import BaseSchema, MetadataBaseSchema, MetadataBaseCreate, MetadataBaseUpdate, MetadataBaseInDBBase 3 | from .msg import Msg 4 | from .token import ( 5 | RefreshTokenCreate, 6 | RefreshTokenUpdate, 7 | RefreshToken, 8 | Token, 9 | TokenPayload, 10 | MagicTokenPayload, 11 | WebToken, 12 | ) 13 | from .user import User, UserCreate, UserInDB, UserUpdate, UserLogin 14 | from .emails import EmailContent, EmailValidation 15 | from .totp import NewTOTP, EnableTOTP 16 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/schemas/base_schema.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from pydantic import ConfigDict, BaseModel, Field 3 | from uuid import UUID 4 | from datetime import date, datetime 5 | import json 6 | 7 | from app.schema_types import BaseEnum 8 | 9 | 10 | class BaseSchema(BaseModel): 11 | @property 12 | def as_db_dict(self): 13 | to_db = self.model_dump(exclude_defaults=True, exclude_none=True, exclude={"identifier", "id"}) # noqa 14 | for key in ["id", "identifier"]: 15 | if key in self.model_dump().keys(): 16 | to_db[key] = self.model_dump()[key].hex 17 | return to_db 18 | 19 | 20 | class MetadataBaseSchema(BaseSchema): 21 | # Receive via API 22 | # https://www.dublincore.org/specifications/dublin-core/dcmi-terms/#section-3 23 | title: str | None = Field(None, description="A human-readable title given to the resource.") # noqa 24 | description: str | None = Field( 25 | None, 26 | description="A short description of the resource.", 27 | ) 28 | isActive: bool | None = Field(default=True, description="Whether the resource is still actively maintained.") # noqa 29 | isPrivate: bool | None = Field( 30 | default=True, 31 | description="Whether the resource is private to team members with appropriate authorisation.", # noqa 32 | ) 33 | 34 | 35 | class MetadataBaseCreate(MetadataBaseSchema): 36 | pass 37 | 38 | 39 | class MetadataBaseUpdate(MetadataBaseSchema): 40 | identifier: UUID = Field(..., description="Automatically generated unique identity for the resource.") 41 | 42 | 43 | class MetadataBaseInDBBase(MetadataBaseSchema): 44 | # Identifier managed programmatically 45 | identifier: UUID = Field(..., description="Automatically generated unique identity for the resource.") 46 | created: date = Field(..., description="Automatically generated date resource was created.") 47 | isActive: bool = Field(..., description="Whether the resource is still actively maintained.") 48 | isPrivate: bool = Field( 49 | ..., 50 | description="Whether the resource is private to team members with appropriate authorisation.", 51 | ) 52 | model_config = ConfigDict(from_attributes=True) 53 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/schemas/emails.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, EmailStr, SecretStr 2 | 3 | 4 | class EmailContent(BaseModel): 5 | email: EmailStr 6 | subject: str 7 | content: str 8 | 9 | 10 | class EmailValidation(BaseModel): 11 | email: EmailStr 12 | subject: str 13 | token: SecretStr 14 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/schemas/msg.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class Msg(BaseModel): 5 | msg: str 6 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/schemas/token.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, ConfigDict, SecretStr 2 | from odmantic import Model, ObjectId 3 | 4 | 5 | class RefreshTokenBase(BaseModel): 6 | token: SecretStr 7 | authenticates: Model | None = None 8 | 9 | 10 | class RefreshTokenCreate(RefreshTokenBase): 11 | authenticates: Model 12 | 13 | 14 | class RefreshTokenUpdate(RefreshTokenBase): 15 | pass 16 | 17 | 18 | class RefreshToken(RefreshTokenUpdate): 19 | model_config = ConfigDict(from_attributes=True) 20 | 21 | 22 | class Token(BaseModel): 23 | access_token: str 24 | refresh_token: str | None = None 25 | token_type: str 26 | 27 | 28 | class TokenPayload(BaseModel): 29 | sub: ObjectId | None = None 30 | refresh: bool | None = False 31 | totp: bool | None = False 32 | 33 | 34 | class MagicTokenPayload(BaseModel): 35 | sub: ObjectId | None = None 36 | fingerprint: ObjectId | None = None 37 | 38 | 39 | class WebToken(BaseModel): 40 | claim: str 41 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/schemas/totp.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, SecretStr 2 | 3 | 4 | class NewTOTP(BaseModel): 5 | secret: SecretStr | None = None 6 | key: str 7 | uri: str 8 | 9 | 10 | class EnableTOTP(BaseModel): 11 | claim: str 12 | uri: str 13 | password: SecretStr | None = None 14 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/schemas/user.py: -------------------------------------------------------------------------------- 1 | from typing_extensions import Annotated 2 | from pydantic import BaseModel, ConfigDict, Field, EmailStr, StringConstraints, field_validator, SecretStr 3 | from odmantic import ObjectId 4 | 5 | 6 | class UserLogin(BaseModel): 7 | username: str 8 | password: str 9 | 10 | 11 | # Shared properties 12 | class UserBase(BaseModel): 13 | email: EmailStr | None = None 14 | email_validated: bool | None = False 15 | is_active: bool | None = True 16 | is_superuser: bool | None = False 17 | full_name: str = "" 18 | 19 | 20 | # Properties to receive via API on creation 21 | class UserCreate(UserBase): 22 | email: EmailStr 23 | password: Annotated[str | None, StringConstraints(min_length=8, max_length=64)] = None # noqa 24 | 25 | 26 | # Properties to receive via API on update 27 | class UserUpdate(UserBase): 28 | original: Annotated[str | None, StringConstraints(min_length=8, max_length=64)] = None # noqa 29 | password: Annotated[str | None, StringConstraints(min_length=8, max_length=64)] = None # noqa 30 | 31 | 32 | class UserInDBBase(UserBase): 33 | id: ObjectId | None = None 34 | model_config = ConfigDict(from_attributes=True) 35 | 36 | 37 | # Additional properties to return via API 38 | class User(UserInDBBase): 39 | hashed_password: bool = Field(default=False, alias="password") 40 | totp_secret: bool = Field(default=False, alias="totp") 41 | model_config = ConfigDict(populate_by_name=True) 42 | 43 | @field_validator("hashed_password", mode="before") 44 | def evaluate_hashed_password(cls, hashed_password): 45 | if hashed_password: 46 | return True 47 | return False 48 | 49 | @field_validator("totp_secret", mode="before") 50 | def evaluate_totp_secret(cls, totp_secret): 51 | if totp_secret: 52 | return True 53 | return False 54 | 55 | 56 | # Additional properties stored in DB 57 | class UserInDB(UserInDBBase): 58 | hashed_password: SecretStr | None = None 59 | totp_secret: SecretStr | None = None 60 | totp_counter: int | None = None 61 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb-labs/full-stack-fastapi-mongodb/ffbc2ca97388f7f90b4e24e808c26b67d5f783cf/{{cookiecutter.project_slug}}/backend/app/app/tests/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb-labs/full-stack-fastapi-mongodb/ffbc2ca97388f7f90b4e24e808c26b67d5f783cf/{{cookiecutter.project_slug}}/backend/app/app/tests/api/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb-labs/full-stack-fastapi-mongodb/ffbc2ca97388f7f90b4e24e808c26b67d5f783cf/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_login.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from fastapi.testclient import TestClient 4 | 5 | from app.core.config import settings 6 | 7 | 8 | def test_get_access_token(client: TestClient) -> None: 9 | login_data = { 10 | "username": settings.FIRST_SUPERUSER, 11 | "password": settings.FIRST_SUPERUSER_PASSWORD, 12 | } 13 | r = client.post(f"{settings.API_V1_STR}/login/oauth", data=login_data) 14 | tokens = r.json() 15 | assert r.status_code == 200 16 | assert "access_token" in tokens 17 | assert tokens["access_token"] 18 | 19 | 20 | def test_use_access_token(client: TestClient, superuser_token_headers: Dict[str, str]) -> None: 21 | r = client.get( 22 | f"{settings.API_V1_STR}/users/", 23 | headers=superuser_token_headers, 24 | ) 25 | result = r.json() 26 | assert r.status_code == 200 27 | assert "email" in result 28 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_users.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from fastapi.testclient import TestClient 4 | from motor.core import AgnosticDatabase 5 | import pytest 6 | 7 | from app import crud 8 | from app.core.config import settings 9 | from app.schemas.user import UserCreate 10 | from app.tests.utils.utils import random_email, random_lower_string 11 | 12 | 13 | @pytest.mark.asyncio 14 | async def test_get_users_superuser_me(client: TestClient, superuser_token_headers: Dict[str, str]) -> None: 15 | r = client.get(f"{settings.API_V1_STR}/users/", headers=superuser_token_headers) 16 | current_user = r.json() 17 | assert current_user 18 | assert current_user["is_active"] is True 19 | assert current_user["is_superuser"] 20 | assert current_user["email"] == settings.FIRST_SUPERUSER 21 | 22 | 23 | @pytest.mark.asyncio 24 | async def test_get_users_normal_user_me(client: TestClient, normal_user_token_headers: Dict[str, str]) -> None: 25 | r = client.get(f"{settings.API_V1_STR}/users/", headers=normal_user_token_headers) 26 | current_user = r.json() 27 | assert current_user 28 | assert current_user["is_active"] is True 29 | assert current_user["is_superuser"] is False 30 | assert current_user["email"] == settings.EMAIL_TEST_USER 31 | 32 | 33 | @pytest.mark.asyncio 34 | async def test_create_user_new_email(client: TestClient, superuser_token_headers: dict, db: AgnosticDatabase) -> None: 35 | username = random_email() 36 | password = random_lower_string() 37 | data = {"email": username, "password": password} 38 | r = client.post( 39 | f"{settings.API_V1_STR}/users/", 40 | headers=superuser_token_headers, 41 | json=data, 42 | ) 43 | assert 200 <= r.status_code < 300 44 | created_user = r.json() 45 | user = await crud.user.get_by_email(db, email=username) 46 | assert user 47 | assert user.email == created_user["email"] 48 | 49 | 50 | @pytest.mark.asyncio 51 | async def test_create_user_existing_username( 52 | client: TestClient, superuser_token_headers: dict, db: AgnosticDatabase 53 | ) -> None: 54 | username = random_email() 55 | # username = email 56 | password = random_lower_string() 57 | user_in = UserCreate(email=username, password=password) 58 | await crud.user.create(db, obj_in=user_in) 59 | data = {"email": username, "password": password} 60 | r = client.post( 61 | f"{settings.API_V1_STR}/users/", 62 | headers=superuser_token_headers, 63 | json=data, 64 | ) 65 | created_user = r.json() 66 | assert r.status_code == 400 67 | assert "_id" not in created_user 68 | 69 | 70 | @pytest.mark.asyncio 71 | async def test_retrieve_users(client: TestClient, superuser_token_headers: dict, db: AgnosticDatabase) -> None: 72 | username = random_email() 73 | password = random_lower_string() 74 | user_in = UserCreate(email=username, password=password) 75 | await crud.user.create(db, obj_in=user_in) 76 | 77 | username2 = random_email() 78 | password2 = random_lower_string() 79 | user_in2 = UserCreate(email=username2, password=password2) 80 | await crud.user.create(db, obj_in=user_in2) 81 | 82 | r = client.get(f"{settings.API_V1_STR}/users/all", headers=superuser_token_headers) 83 | all_users = r.json() 84 | 85 | assert len(all_users) > 1 86 | for item in all_users: 87 | assert "email" in item 88 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Dict, Generator 3 | 4 | import pytest 5 | import pytest_asyncio 6 | from fastapi.testclient import TestClient 7 | from motor.core import AgnosticDatabase 8 | 9 | from app.core.config import settings 10 | from app.db.init_db import init_db 11 | from app.main import app 12 | from app.db.session import MongoDatabase, _MongoClientSingleton 13 | from app.tests.utils.user import authentication_token_from_email 14 | from app.tests.utils.utils import get_superuser_token_headers 15 | 16 | TEST_DATABASE = "test" 17 | settings.MONGO_DATABASE = TEST_DATABASE 18 | 19 | 20 | @pytest.fixture(scope="session") 21 | def event_loop(): 22 | try: 23 | loop = asyncio.get_running_loop() 24 | except RuntimeError: 25 | loop = asyncio.new_event_loop() 26 | yield loop 27 | loop.close() 28 | 29 | 30 | @pytest_asyncio.fixture(scope="session") 31 | async def db() -> Generator: 32 | db = MongoDatabase() 33 | _MongoClientSingleton.instance.mongo_client.get_io_loop = asyncio.get_event_loop 34 | await init_db(db) 35 | yield db 36 | 37 | 38 | @pytest.fixture(scope="session") 39 | def client(db) -> Generator: 40 | with TestClient(app) as c: 41 | yield c 42 | 43 | 44 | @pytest.fixture(scope="module") 45 | def superuser_token_headers(client: TestClient) -> Dict[str, str]: 46 | return get_superuser_token_headers(client) 47 | 48 | 49 | @pytest_asyncio.fixture(scope="module") 50 | async def normal_user_token_headers(client: TestClient, db: AgnosticDatabase) -> Dict[str, str]: 51 | return await authentication_token_from_email(client=client, email=settings.EMAIL_TEST_USER, db=db) 52 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/crud/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb-labs/full-stack-fastapi-mongodb/ffbc2ca97388f7f90b4e24e808c26b67d5f783cf/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_user.py: -------------------------------------------------------------------------------- 1 | from fastapi.encoders import jsonable_encoder 2 | from motor.core import AgnosticDatabase 3 | import pytest 4 | 5 | from app import crud 6 | from app.core.security import verify_password 7 | from app.schemas.user import UserCreate, UserUpdate 8 | from app.tests.utils.utils import random_email, random_lower_string 9 | 10 | 11 | @pytest.mark.asyncio 12 | async def test_create_user(db: AgnosticDatabase) -> None: 13 | email = random_email() 14 | password = random_lower_string() 15 | user_in = UserCreate(email=email, password=password) 16 | user = await crud.user.create(db, obj_in=user_in) 17 | assert user.email == email 18 | assert hasattr(user, "hashed_password") 19 | 20 | 21 | @pytest.mark.asyncio 22 | async def test_authenticate_user(db: AgnosticDatabase) -> None: 23 | email = random_email() 24 | password = random_lower_string() 25 | user_in = UserCreate(email=email, password=password) 26 | user = await crud.user.create(db, obj_in=user_in) 27 | authenticated_user = await crud.user.authenticate(db, email=email, password=password) 28 | assert authenticated_user 29 | assert user.email == authenticated_user.email 30 | 31 | 32 | @pytest.mark.asyncio 33 | async def test_not_authenticate_user(db: AgnosticDatabase) -> None: 34 | email = random_email() 35 | password = random_lower_string() 36 | user = await crud.user.authenticate(db, email=email, password=password) 37 | assert user is None 38 | 39 | 40 | @pytest.mark.asyncio 41 | async def test_check_if_user_is_active(db: AgnosticDatabase) -> None: 42 | email = random_email() 43 | password = random_lower_string() 44 | user_in = UserCreate(email=email, password=password) 45 | user = await crud.user.create(db, obj_in=user_in) 46 | is_active = crud.user.is_active(user) 47 | assert is_active is True 48 | 49 | 50 | @pytest.mark.asyncio 51 | async def test_check_if_user_is_active_inactive(db: AgnosticDatabase) -> None: 52 | email = random_email() 53 | password = random_lower_string() 54 | user_in = UserCreate(email=email, password=password, disabled=True) 55 | user = await crud.user.create(db, obj_in=user_in) 56 | is_active = crud.user.is_active(user) 57 | assert is_active 58 | 59 | 60 | @pytest.mark.asyncio 61 | async def test_check_if_user_is_superuser(db: AgnosticDatabase) -> None: 62 | email = random_email() 63 | password = random_lower_string() 64 | user_in = UserCreate(email=email, password=password, is_superuser=True) 65 | user = await crud.user.create(db, obj_in=user_in) 66 | is_superuser = crud.user.is_superuser(user) 67 | assert is_superuser is True 68 | 69 | 70 | @pytest.mark.asyncio 71 | async def test_check_if_user_is_superuser_normal_user(db: AgnosticDatabase) -> None: 72 | username = random_email() 73 | password = random_lower_string() 74 | user_in = UserCreate(email=username, password=password) 75 | user = await crud.user.create(db, obj_in=user_in) 76 | is_superuser = crud.user.is_superuser(user) 77 | assert is_superuser is False 78 | 79 | 80 | @pytest.mark.asyncio 81 | async def test_get_user(db: AgnosticDatabase) -> None: 82 | password = random_lower_string() 83 | username = random_email() 84 | user_in = UserCreate(email=username, password=password, is_superuser=True) 85 | user = await crud.user.create(db, obj_in=user_in) 86 | user_2 = await crud.user.get(db, id=user.id) 87 | assert user_2 88 | assert user.email == user_2.email 89 | assert user.model_dump() == user_2.model_dump() 90 | 91 | 92 | @pytest.mark.asyncio 93 | async def test_update_user(db: AgnosticDatabase) -> None: 94 | password = random_lower_string() 95 | email = random_email() 96 | user_in = UserCreate(email=email, password=password, is_superuser=True) 97 | user = await crud.user.create(db, obj_in=user_in) 98 | new_password = random_lower_string() 99 | user_in_update = UserUpdate(password=new_password, is_superuser=True) 100 | await crud.user.update(db, db_obj=user, obj_in=user_in_update) 101 | user_2 = await crud.user.get(db, id=user.id) 102 | assert user_2 103 | assert user.email == user_2.email 104 | assert verify_password(plain_password=new_password, hashed_password=user_2.hashed_password) 105 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb-labs/full-stack-fastapi-mongodb/ffbc2ca97388f7f90b4e24e808c26b67d5f783cf/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/utils/user.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from fastapi.testclient import TestClient 4 | from motor.core import AgnosticDatabase 5 | 6 | from app import crud 7 | from app.core.config import settings 8 | from app.models.user import User 9 | from app.schemas.user import UserCreate, UserUpdate 10 | from app.tests.utils.utils import random_email, random_lower_string 11 | 12 | 13 | def user_authentication_headers(*, client: TestClient, email: str, password: str) -> Dict[str, str]: 14 | data = {"username": email, "password": password} 15 | 16 | r = client.post(f"{settings.API_V1_STR}/login/oauth", data=data) 17 | response = r.json() 18 | auth_token = response["access_token"] 19 | headers = {"Authorization": f"Bearer {auth_token}"} 20 | return headers 21 | 22 | 23 | async def authentication_token_from_email(*, client: TestClient, email: str, db: AgnosticDatabase) -> Dict[str, str]: 24 | """ 25 | Return a valid token for the user with given email. 26 | 27 | If the user doesn't exist it is created first. 28 | """ 29 | password = random_lower_string() 30 | user = await crud.user.get_by_email(db, email=email) 31 | if not user: 32 | user_in_create = UserCreate(username=email, email=email, password=password) 33 | user = await crud.user.create(db, obj_in=user_in_create) 34 | else: 35 | user_in_update = UserUpdate(password=password) 36 | user = await crud.user.update(db, db_obj=user, obj_in=user_in_update) 37 | 38 | return user_authentication_headers(client=client, email=email, password=password) 39 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/utils/utils.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | from typing import Dict 4 | 5 | from fastapi.testclient import TestClient 6 | 7 | from app.core.config import settings 8 | 9 | 10 | def random_lower_string() -> str: 11 | return "".join(random.choices(string.ascii_lowercase, k=32)) 12 | 13 | 14 | def random_email() -> str: 15 | return f"{random_lower_string()}@{random_lower_string()}.com" 16 | 17 | 18 | def get_superuser_token_headers(client: TestClient) -> Dict[str, str]: 19 | login_data = { 20 | "username": settings.FIRST_SUPERUSER, 21 | "password": settings.FIRST_SUPERUSER_PASSWORD, 22 | } 23 | r = client.post(f"{settings.API_V1_STR}/login/oauth", data=login_data) 24 | tokens = r.json() 25 | a_token = tokens["access_token"] 26 | headers = {"Authorization": f"Bearer {a_token}"} 27 | return headers 28 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests_pre_start.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from app.db.session import ping 4 | 5 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed 6 | 7 | logging.basicConfig(level=logging.INFO) 8 | logger = logging.getLogger(__name__) 9 | 10 | max_tries = 6 * 1 # 5 minutes 11 | wait_seconds = 1 12 | 13 | 14 | @retry( 15 | stop=stop_after_attempt(max_tries), 16 | wait=wait_fixed(wait_seconds), 17 | before=before_log(logger, logging.INFO), 18 | after=after_log(logger, logging.WARN), 19 | ) 20 | async def init() -> None: 21 | try: 22 | # Try to create session to check if DB is awake 23 | await ping() 24 | except Exception as e: 25 | logger.error(e) 26 | raise e 27 | 28 | 29 | async def main() -> None: 30 | logger.info("Initializing service") 31 | await init() 32 | logger.info("Service finished initializing") 33 | 34 | 35 | if __name__ == "__main__": 36 | asyncio.run(main()) 37 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/utilities/__init__.py: -------------------------------------------------------------------------------- 1 | from .email import ( 2 | send_email, 3 | send_test_email, 4 | send_web_contact_email, 5 | send_magic_login_email, 6 | send_reset_password_email, 7 | send_new_account_email, 8 | send_email_validation_email, 9 | ) 10 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/worker/__init__.py: -------------------------------------------------------------------------------- 1 | from app.core.celery_app import celery_app 2 | 3 | from .tests import test_celery 4 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/worker/tests.py: -------------------------------------------------------------------------------- 1 | import sentry_sdk 2 | import asyncio 3 | 4 | from app.core.celery_app import celery_app 5 | from app.core.config import settings 6 | 7 | client_sentry = sentry_sdk.init( 8 | dsn=settings.SENTRY_DSN, 9 | # Set traces_sample_rate to 1.0 to capture 100% 10 | # of transactions for tracing. 11 | traces_sample_rate=1.0, 12 | # Set profiles_sample_rate to 1.0 to profile 100% 13 | # of sampled transactions. 14 | # We recommend adjusting this value in production. 15 | profiles_sample_rate=1.0, 16 | ) 17 | 18 | 19 | @celery_app.task(acks_late=True) 20 | async def test_celery(word: str) -> str: 21 | await asyncio.sleep(5) 22 | return f"test task return {word}" 23 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | plugins = pydantic.mypy, sqlmypy 3 | ignore_missing_imports = True 4 | disallow_untyped_defs = True 5 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/prestart.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Let the DB start 4 | python /app/app/backend_pre_start.py 5 | 6 | # Create initial data in DB 7 | python /app/app/initial_data.py 8 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "app" 7 | dynamic = ["version"] 8 | description = '' 9 | readme = "README.md" 10 | requires-python = ">=3.11" 11 | license = "MIT" 12 | keywords = [] 13 | authors = [ 14 | { name = "U.N. Owen", email = "admin@dev-fsfp.com" }, 15 | ] 16 | classifiers = [ 17 | "Development Status :: 4 - Beta", 18 | "Programming Language :: Python", 19 | "Programming Language :: Python :: 3.11", 20 | ] 21 | dependencies = [ 22 | "inboard[fastapi]==0.56.*", 23 | "python-multipart>=0.0.5", 24 | "email-validator>=1.3.0", 25 | "requests>=2.28.1", 26 | "celery>=5.2.7", 27 | "passlib[bcrypt]>=1.7.4", 28 | "tenacity>=8.1.0", 29 | "emails>=0.6.0", 30 | "sentry-sdk>=2.13.0", 31 | "jinja2>=3.1.2", 32 | "python-jose[cryptography]>=3.3.0", 33 | "pydantic>=2.0,<2.7", 34 | "pydantic-settings>=2.0.3", 35 | "httpx>=0.23.1", 36 | "psycopg2-binary>=2.9.5", 37 | "setuptools>=65.6.3", 38 | "motor>=3.3.1", 39 | "pytest==7.4.2", 40 | "pytest-cov==4.1.0", 41 | "pytest-asyncio>=0.21.0", 42 | "argon2-cffi==23.1.0", 43 | "argon2-cffi-bindings==21.2.0", 44 | "odmantic>=1.0,<2.0", 45 | ] 46 | 47 | [project.optional-dependencies] 48 | checks = [ 49 | "black>=23.1.0", 50 | "mypy>=1.0.0", 51 | "isort>=5.11.2", 52 | "autoflake>=2.0.0", 53 | "flake8>=6.0.0", 54 | ] 55 | 56 | [project.urls] 57 | Documentation = "https://github.com/unknown/app#readme" 58 | Issues = "https://github.com/unknown/app/issues" 59 | Source = "https://github.com/unknown/app" 60 | 61 | [tool.hatch.version] 62 | path = "app/__version__.py" 63 | 64 | [dirs.env] 65 | virtual = "./.venv" 66 | 67 | [tool.hatch.envs.default] 68 | dev-mode = true 69 | python="3.11" 70 | dependencies = [] 71 | 72 | [tool.hatch.build.targets.sdist] 73 | include = ["/app"] 74 | 75 | [tool.hatch.envs.production] 76 | dev-mode = false 77 | features = [] 78 | path = ".venv" 79 | 80 | [tool.hatch.envs.lint] 81 | detached = true 82 | dependencies = [ 83 | "black>=23.1.0", 84 | "mypy>=1.0.0", 85 | "isort>=5.11.2", 86 | "python>=3.11", 87 | ] 88 | [tool.hatch.envs.lint.scripts] 89 | style = [ 90 | "isort --check --diff {args:.}", 91 | "black --check --diff {args:.}", 92 | ] 93 | fmt = [ 94 | "black {args:.}", 95 | "isort {args:.}", 96 | "style", 97 | ] 98 | all = [ 99 | "style", 100 | "typing", 101 | ] 102 | 103 | [tool.black] 104 | target-version = ["py311"] 105 | line-length = 120 106 | 107 | [tool.isort] 108 | profile = "black" 109 | multi_line_output = 3 110 | include_trailing_comma = true 111 | force_grid_wrap = 0 112 | line_length = 120 113 | src_paths = ["app", "tests"] 114 | 115 | [tool.mypy] 116 | files = ["**/*.py"] 117 | plugins = "pydantic.mypy" 118 | show_error_codes = true 119 | strict = true -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/scripts/format-imports.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | set -x 3 | 4 | # Sort imports one per line, so autoflake can remove unused imports 5 | isort --recursive --force-single-line-imports --apply app 6 | sh ./scripts/format.sh 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/scripts/format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | set -x 3 | 4 | autoflake --remove-all-unused-imports --recursive --remove-unused-variables --in-place app --exclude=__init__.py 5 | black app 6 | isort --recursive --apply app 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -x 4 | 5 | mypy app 6 | black app --check 7 | isort --recursive --check-only app 8 | flake8 9 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/scripts/test-cov-html.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | bash scripts/test.sh --cov-report=html "${@}" 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | pytest --cov=app --cov-report=term-missing app/tests "${@}" 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/tests-start.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | set -e 3 | 4 | hatch run python /app/app/tests_pre_start.py 5 | 6 | bash ./scripts/test.sh "$@" 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/worker-start.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | set -e 3 | 4 | hatch run python /app/app/celeryworker_pre_start.py 5 | hatch run celery -A app.worker worker -l info -Q main-queue -c 1 6 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/backend.dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/br3ndonland/inboard:fastapi-0.51-python3.11 2 | 3 | # Use file.name* in case it doesn't exist in the repo 4 | COPY ./app/ /app/ 5 | WORKDIR /app/ 6 | ENV HATCH_ENV_TYPE_VIRTUAL_PATH=.venv 7 | RUN hatch env prune && hatch env create production && pip install --upgrade setuptools 8 | 9 | # /start Project-specific dependencies 10 | # RUN apt-get update && apt-get install -y --no-install-recommends \ 11 | # && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 12 | # WORKDIR /app/ 13 | # /end Project-specific dependencies 14 | 15 | # For development, Jupyter remote kernel 16 | # Using inside the container: 17 | # jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888 18 | ARG INSTALL_JUPYTER=false 19 | RUN bash -c "if [ $INSTALL_JUPYTER == 'true' ] ; then pip install jupyterlab ; fi" 20 | 21 | ARG BACKEND_APP_MODULE=app.main:app 22 | ARG BACKEND_PRE_START_PATH=/app/prestart.sh 23 | ARG BACKEND_PROCESS_MANAGER=gunicorn 24 | ARG BACKEND_WITH_RELOAD=false 25 | ENV APP_MODULE=${BACKEND_APP_MODULE} PRE_START_PATH=${BACKEND_PRE_START_PATH} PROCESS_MANAGER=${BACKEND_PROCESS_MANAGER} WITH_RELOAD=${BACKEND_WITH_RELOAD} -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/celeryworker.dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11 2 | WORKDIR /app/ 3 | ARG \ 4 | HATCH_VERSION=1.7.0 \ 5 | PIPX_VERSION=1.2.0 6 | ENV \ 7 | C_FORCE_ROOT=1 \ 8 | HATCH_ENV_TYPE_VIRTUAL_PATH=.venv \ 9 | HATCH_VERSION=$HATCH_VERSION \ 10 | PATH=/opt/pipx/bin:/app/.venv/bin:$PATH \ 11 | PIPX_BIN_DIR=/opt/pipx/bin \ 12 | PIPX_HOME=/opt/pipx/home \ 13 | PIPX_VERSION=$PIPX_VERSION \ 14 | PYTHONPATH=/app 15 | COPY ./app/ /app/ 16 | RUN python -m pip install --no-cache-dir --upgrade pip "pipx==$PIPX_VERSION" 17 | RUN pipx install "hatch==$HATCH_VERSION" 18 | RUN hatch env prune && hatch env create production 19 | RUN chmod +x /app/worker-start.sh 20 | 21 | # For development, Jupyter remote kernel, Hydrogen 22 | # Using inside the container: 23 | # jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888 24 | # ARG INSTALL_JUPYTER=false 25 | # RUN bash -c "if [ $INSTALL_JUPYTER == 'true' ] ; then pip install jupyterlab ; fi" 26 | 27 | CMD ["bash", "worker-start.sh"] 28 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/cookiecutter-config-file.yml: -------------------------------------------------------------------------------- 1 | default_context: 2 | project_name: '{{ cookiecutter.project_name }}' 3 | project_slug: '{{ cookiecutter.project_slug }}' 4 | domain_main: '{{ cookiecutter.domain_main }}' 5 | domain_staging: '{{ cookiecutter.domain_staging }}' 6 | domain_base_api_url: '{{cookiecutter.domain_base_api_url}}' 7 | domain_base_ws_url: '{{cookiecutter.domain_base_ws_url}}' 8 | docker_swarm_stack_name_main: '{{ cookiecutter.docker_swarm_stack_name_main }}' 9 | docker_swarm_stack_name_staging: '{{ cookiecutter.docker_swarm_stack_name_staging }}' 10 | secret_key: '{{ cookiecutter.secret_key }}' 11 | first_superuser: '{{ cookiecutter.first_superuser }}' 12 | first_superuser_password: '{{ cookiecutter.first_superuser_password }}' 13 | backend_cors_origins: '{{ cookiecutter.backend_cors_origins }}' 14 | smtp_tls: '{{ cookiecutter.smtp_tls }}' 15 | smtp_port: '{{ cookiecutter.smtp_port }}' 16 | smtp_host: '{{ cookiecutter.smtp_host }}' 17 | smtp_user: '{{ cookiecutter.smtp_user }}' 18 | smtp_password: '{{ cookiecutter.smtp_password }}' 19 | smtp_emails_from_email: '{{ cookiecutter.smtp_emails_from_email }}' 20 | smtp_emails_from_name: '{{cookiecutter.smtp_emails_from_name}}' 21 | smtp_emails_to_email: '{{cookiecutter.smtp_emails_to_email}}' 22 | mongodb_uri: '{{cookiecutter.mongodb_uri}}' 23 | mongodb_database: '{{cookiecutter.mongodb_database}}' 24 | traefik_constraint_tag: '{{ cookiecutter.traefik_constraint_tag }}' 25 | traefik_constraint_tag_staging: '{{ cookiecutter.traefik_constraint_tag_staging }}' 26 | traefik_public_constraint_tag: '{{ cookiecutter.traefik_public_constraint_tag }}' 27 | flower_auth: '{{ cookiecutter.flower_auth }}' 28 | sentry_dsn: '{{ cookiecutter.sentry_dsn }}' 29 | docker_image_prefix: '{{ cookiecutter.docker_image_prefix }}' 30 | docker_image_backend: '{{ cookiecutter.docker_image_backend }}' 31 | docker_image_celeryworker: '{{ cookiecutter.docker_image_celeryworker }}' 32 | docker_image_frontend: '{{ cookiecutter.docker_image_frontend }}' 33 | _copy_without_render: [frontend/**/*.html, frontend/**/*.vue, frontend/.nuxt/*, frontend/node_modules/*, backend/app/app/email-templates/**] 34 | _template: ./ 35 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | services: 3 | proxy: 4 | ports: 5 | - "80:80" 6 | - "8090:8080" 7 | command: 8 | # Enable Docker in Traefik, so that it reads labels from Docker services 9 | - --providers.docker 10 | # Add a constraint to only use services with the label for this stack 11 | # from the env var TRAEFIK_TAG 12 | - --providers.docker.constraints=Label(`traefik.constraint-label-stack`, `${TRAEFIK_TAG?Variable not set}`) 13 | # Do not expose all Docker services, only the ones explicitly exposed 14 | - --providers.docker.exposedbydefault=false 15 | # Disable Docker Swarm mode for local development 16 | # - --providers.docker.swarmmode 17 | # Enable the access log, with HTTP requests 18 | - --accesslog 19 | # Enable the Traefik log, for configurations and errors 20 | - --log 21 | # Enable the Dashboard and API 22 | - --api 23 | # Enable the Dashboard and API in insecure mode for local development 24 | - --api.insecure=true 25 | labels: 26 | - traefik.enable=true 27 | - traefik.http.routers.${STACK_NAME?Variable not set}-traefik-public-http.rule=Host(`${DOMAIN?Variable not set}`) 28 | - traefik.http.services.${STACK_NAME?Variable not set}-traefik-public.loadbalancer.server.port=80 29 | 30 | flower: 31 | ports: 32 | - "5555:5555" 33 | 34 | backend: 35 | ports: 36 | - "8888:8888" 37 | volumes: 38 | - ./backend/app/app:/app/app 39 | environment: 40 | - JUPYTER=jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888 41 | - SERVER_HOST=http://${DOMAIN?Variable not set} 42 | build: 43 | context: ./backend 44 | dockerfile: backend.dockerfile 45 | args: 46 | BACKEND_APP_MODULE: ${BACKEND_APP_MODULE-app.main:app} 47 | BACKEND_PRE_START_PATH: ${BACKEND_PRE_START_PATH-/app/prestart.sh} 48 | BACKEND_PROCESS_MANAGER: ${BACKEND_PROCESS_MANAGER-uvicorn} 49 | BACKEND_WITH_RELOAD: ${BACKEND_WITH_RELOAD-true} 50 | INSTALL_DEV: ${INSTALL_DEV-true} 51 | INSTALL_JUPYTER: ${INSTALL_JUPYTER-true} 52 | labels: 53 | - traefik.enable=true 54 | - traefik.constraint-label-stack=${TRAEFIK_TAG?Variable not set} 55 | - traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.rule=PathPrefix(`/api/v`) || PathPrefix(`/docs`) || PathPrefix(`/redoc`) 56 | - traefik.http.services.${STACK_NAME?Variable not set}-backend.loadbalancer.server.port=80 57 | 58 | celeryworker: 59 | volumes: 60 | - ./backend/app:/app 61 | environment: 62 | - RUN=celery worker -A app.worker -l info -Q main-queue -c 1 63 | - JUPYTER=jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888 64 | - SERVER_HOST=http://${DOMAIN?Variable not set} 65 | build: 66 | context: ./backend 67 | dockerfile: celeryworker.dockerfile 68 | args: 69 | INSTALL_DEV: ${INSTALL_DEV-true} 70 | INSTALL_JUPYTER: ${INSTALL_JUPYTER-true} 71 | 72 | frontend: 73 | ports: 74 | - "3000:3000" 75 | build: 76 | context: ./frontend 77 | target: runner 78 | labels: 79 | - traefik.enable=true 80 | - traefik.constraint-label-stack=${TRAEFIK_TAG?Variable not set} 81 | - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.rule=PathPrefix(`/`) 82 | - traefik.http.services.${STACK_NAME?Variable not set}-frontend.loadbalancer.server.port=80 83 | 84 | volumes: 85 | node_modules: 86 | 87 | networks: 88 | traefik-public: 89 | # For local dev, don't expect an external Traefik network 90 | external: false 91 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/.env.local: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_URL=http://localhost/api/v1 -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next", "next/core-web-vitals", "prettier"], 3 | "rules": { 4 | "@next/next/no-img-element": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .next 2 | node_modules 3 | *-lock.json 4 | .DS_STORE -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine AS base 2 | 3 | # Install dependencies only when needed 4 | FROM base AS deps 5 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 6 | RUN apk add --no-cache libc6-compat 7 | WORKDIR /frontend 8 | 9 | # Install dependencies based on the preferred package manager 10 | COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ 11 | RUN \ 12 | if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ 13 | elif [ -f package-lock.json ]; then npm ci; \ 14 | elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \ 15 | else npm install && npm ci; \ 16 | fi 17 | 18 | # Rebuild the source code only when needed 19 | FROM base AS builder 20 | WORKDIR /frontend 21 | COPY --from=deps /frontend/node_modules ./node_modules 22 | COPY . . 23 | 24 | # Next.js collects completely anonymous telemetry data about general usage. 25 | # Learn more here: https://nextjs.org/telemetry 26 | # Uncomment the following line in case you want to disable telemetry during the build. 27 | ENV NEXT_TELEMETRY_DISABLED 1 28 | 29 | # If using npm comment out above and use below instead 30 | RUN npm run build 31 | 32 | # Production image, copy all the files and run next 33 | FROM base AS runner 34 | WORKDIR /frontend 35 | 36 | ENV NODE_ENV production 37 | # Uncomment the following line in case you want to disable telemetry during runtime. 38 | ENV NEXT_TELEMETRY_DISABLED 1 39 | 40 | RUN addgroup --system --gid 1001 nodejs 41 | RUN adduser --system --uid 1001 nextjs 42 | 43 | COPY --from=builder /frontend/public ./public 44 | 45 | # Set the correct permission for prerender cache 46 | RUN mkdir .next 47 | RUN chown nextjs:nodejs .next 48 | 49 | # Automatically leverage output traces to reduce image size 50 | # https://nextjs.org/docs/advanced-features/output-file-tracing 51 | COPY --from=builder --chown=nextjs:nodejs /frontend/.next/standalone ./ 52 | COPY --from=builder --chown=nextjs:nodejs /frontend/.next/static ./.next/static 53 | 54 | USER nextjs 55 | 56 | EXPOSE 3000 57 | ENV PORT 3000 58 | 59 | # set hostname to localhost 60 | ENV HOSTNAME "0.0.0.0" 61 | 62 | CMD [ "node", "server.js" ] -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/app/about/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next"; 2 | import { getPostData } from "../lib/utilities/posts"; 3 | 4 | type Post = { 5 | id: string; 6 | content: string; 7 | title: string; 8 | description: string; 9 | author: string; 10 | publishedAt: string; 11 | }; 12 | 13 | const aboutPath = "app/content/"; 14 | 15 | export const metadata: Metadata = { 16 | title: "Getting started with a base project", 17 | }; 18 | 19 | export default async function About() { 20 | const data: Post = await getPostData("about", aboutPath); 21 | 22 | return ( 23 | <> 24 |
25 |
26 |
27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/app/assets/css/main.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @tailwind base; 3 | 4 | html, 5 | body, 6 | #__nuxt, 7 | #__layout { 8 | height: 100% !important; 9 | width: 100% !important; 10 | } 11 | 12 | @tailwind components; 13 | @tailwind utilities; -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/app/authentication/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next"; 2 | import { getPostData } from "../lib/utilities/posts"; 3 | 4 | type Post = { 5 | id: string; 6 | content: string; 7 | title: string; 8 | description: string; 9 | author: string; 10 | publishedAt: string; 11 | }; 12 | 13 | const aboutPath = "app/content/"; 14 | 15 | export const metadata: Metadata = { 16 | title: "Authentication with Magic and Oauth2", 17 | }; 18 | 19 | export default async function Authentication() { 20 | const data: Post = await getPostData("authentication", aboutPath); 21 | 22 | return ( 23 | <> 24 |
25 |
26 |
27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/app/blog/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { getPostData } from "../../lib/utilities/posts"; 2 | import { readableDate } from "../../lib/utilities/textual"; 3 | 4 | type Params = { 5 | id: string; 6 | }; 7 | 8 | type Props = { 9 | params: Params; 10 | }; 11 | 12 | type Post = { 13 | title: string; 14 | publishedAt: string; 15 | content: string; 16 | author: string; 17 | }; 18 | 19 | export async function generateMetadata({ params }: Props) { 20 | const postData: Post = await getPostData(params.id); 21 | 22 | return { 23 | title: postData.title, 24 | }; 25 | } 26 | 27 | // -< Post >- 28 | export default async function Post({ params }: Props) { 29 | const data: Post = await getPostData(params.id); 30 | 31 | return ( 32 | <> 33 |
34 |

35 | 36 | {data.author}, {readableDate(data.publishedAt)} 37 | 38 |

39 |
40 |
41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/app/blog/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { getSortedPostsData } from "../lib/utilities/posts"; 3 | import { readableDate } from "../lib/utilities/textual"; 4 | 5 | type PostMeta = { 6 | id: string; 7 | title: string; 8 | description: string; 9 | author: string; 10 | publishedAt: string; 11 | categories: string[]; 12 | }; 13 | 14 | const title = "Recent blog posts"; 15 | const description = "Thoughts from the world of me."; 16 | 17 | const renderPost = (post: PostMeta) => { 18 | let categories = post.categories.map((category) => ( 19 | 23 | {category.trim()} 24 | 25 | )); 26 | 27 | return ( 28 |
29 |
{categories}
30 | 31 |

{post.title}

32 |

{post.description}

33 | 34 |
35 |
36 |

{post.author}

37 |
38 | 41 |
42 |
43 |
44 |
45 | ); 46 | }; 47 | 48 | export default function BlogHome() { 49 | const postsList: PostMeta[] = getSortedPostsData(); 50 | const posts = postsList.map((post) => renderPost(post)); 51 | 52 | return ( 53 |
54 |
55 |
56 |

57 | {title} 58 |

59 |

{description}

60 |
61 |
62 | {posts} 63 |
64 |
65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/app/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { siteName } from "../lib/utilities/generic"; 5 | 6 | const githubIcon = () => { 7 | return ( 8 | 20 | ); 21 | }; 22 | 23 | const mastodonIcon = () => { 24 | return ( 25 | 37 | ); 38 | }; 39 | 40 | const footerNavigation = { 41 | main: [ 42 | { name: "About", to: "/about" }, 43 | { name: "Authentication", to: "/authentication" }, 44 | { name: "Blog", to: "/blog" }, 45 | ], 46 | social: [ 47 | { 48 | name: "GitHub", 49 | // TODO: Switch to mongo-labs 50 | href: "https://github.com/mongodb-labs/full-stack-fastapi-mongodb", 51 | icon: githubIcon, 52 | }, 53 | { 54 | name: "Mastodon", 55 | // TODO: Switch to mongo-labs? 56 | href: "https://wandering.shop/@GavinChait", 57 | icon: mastodonIcon, 58 | }, 59 | ], 60 | }; 61 | 62 | const renderNavigation = () => { 63 | return footerNavigation.main.map((item) => ( 64 |
65 | 69 | {item.name} 70 | 71 |
72 | )); 73 | }; 74 | 75 | const renderSocials = () => { 76 | return footerNavigation.social.map((item) => ( 77 | 82 | {item.name} 83 | {item.icon()} 84 | 85 | )); 86 | }; 87 | 88 | export default function Footer() { 89 | return ( 90 |
91 |
92 | 98 |
99 | {renderSocials()} 100 |
101 |
102 |

103 | © 2023 {siteName}. All rights reserved. 104 |

105 |
106 |
107 |
108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/app/components/Navigation.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Disclosure } from "@headlessui/react"; 4 | import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline"; 5 | import Link from "next/link"; 6 | import AlertsButton from "./alerts/AlertsButton"; 7 | import dynamic from "next/dynamic"; 8 | const AuthenticationNavigation = dynamic( 9 | () => import("./authentication/AuthenticationNavigation"), 10 | { ssr: false }, 11 | ); 12 | 13 | const navigation = [ 14 | { name: "About", to: "/about" }, 15 | { name: "Authentication", to: "/authentication" }, 16 | { name: "Blog", to: "/blog" }, 17 | ]; 18 | 19 | const renderIcon = (open: boolean) => { 20 | if (!open) { 21 | return