├── .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 |
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 |
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 |
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 ;
22 | } else {
23 | return ;
24 | }
25 | };
26 |
27 | const renderNavLinks = (style: string) => {
28 | return navigation.map((nav) => (
29 |
30 | {nav.name}
31 |
32 | ));
33 | };
34 | export default function Navigation() {
35 | return (
36 |
37 |
38 | {({ open }) => (
39 | <>
40 |
41 |
42 |
43 | {/* Mobile menu button */}
44 |
45 | Open main menu
46 | {renderIcon(open)}
47 |
48 |
49 |
50 |
51 |
52 |

57 |

62 |
63 |
64 |
65 | {renderNavLinks(
66 | "inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-900 hover:text-rose-500",
67 | )}
68 |
69 |
70 |
74 |
75 |
76 |
77 |
78 | {renderNavLinks(
79 | "block hover:border-l-4 hover:border-rose-500 hover:bg-rose-50 py-2 pl-3 pr-4 text-base font-medium text-rose-700",
80 | )}
81 |
82 |
83 | >
84 | )}
85 |
86 |
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/components/Notification.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | CheckCircleIcon,
5 | ExclamationCircleIcon,
6 | InformationCircleIcon,
7 | } from "@heroicons/react/24/outline";
8 | import { XMarkIcon } from "@heroicons/react/20/solid";
9 | import { useEffect, useState } from "react";
10 | import { first, removeNotice, timeoutNotice } from "../lib/slices/toastsSlice";
11 | import { useAppDispatch, useAppSelector } from "../lib/hooks";
12 | import { RootState } from "../lib/store";
13 | import { Transition } from "@headlessui/react";
14 |
15 | const renderIcon = (iconName?: string) => {
16 | if (iconName === "success") {
17 | return (
18 |
19 | );
20 | } else if (iconName === "error") {
21 | return (
22 |
26 | );
27 | } else if (iconName === "information") {
28 | return (
29 |
33 | );
34 | }
35 | };
36 |
37 | export default function Notification() {
38 | const [show, setShow] = useState(false);
39 | const dispatch = useAppDispatch();
40 |
41 | const firstNotification = useAppSelector((state: RootState) => first(state));
42 |
43 | useEffect(() => {
44 | async function push() {
45 | if (firstNotification) {
46 | setShow(true);
47 | await dispatch(timeoutNotice(firstNotification));
48 | setShow(false);
49 | }
50 | }
51 |
52 | push();
53 | });
54 |
55 | return (
56 |
60 |
61 |
71 | {show && (
72 |
73 |
74 | {firstNotification && (
75 |
76 |
77 | {renderIcon(firstNotification.icon)}
78 |
79 |
80 |
81 | {firstNotification.title}
82 |
83 |
84 | {firstNotification.content}
85 |
86 |
87 |
88 |
96 |
97 |
98 | )}
99 |
100 |
101 | )}
102 |
103 |
104 |
105 | );
106 | }
107 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/components/alerts/AlertsButton.tsx:
--------------------------------------------------------------------------------
1 | import { BellIcon } from "@heroicons/react/24/outline";
2 |
3 | export default function AlertsButton() {
4 | return (
5 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/components/authentication/AuthenticationNavigation.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useAppDispatch, useAppSelector } from "../../lib/hooks";
4 | import type { RootState } from "../../lib/store";
5 | import { Menu, Transition } from "@headlessui/react";
6 | import { ArrowLeftOnRectangleIcon } from "@heroicons/react/24/outline";
7 | import Link from "next/link";
8 | import { loggedIn, logout } from "../../lib/slices/authSlice";
9 | import { useRouter } from "next/navigation";
10 |
11 | const navigation = [{ name: "Settings", to: "/settings" }];
12 | const redirectRoute = "/";
13 |
14 | const renderNavLinks = () => {
15 | return navigation.map((nav) => (
16 |
17 | {({ active }) => (
18 |
26 | {nav.name}
27 |
28 | )}
29 |
30 | ));
31 | };
32 |
33 | const renderUser = (loggedIn: boolean) => {
34 | if (!loggedIn) {
35 | return (
36 |
40 |
41 |
42 | );
43 | } else {
44 | return (
45 |
46 | Open user menu
47 |
52 |
53 | );
54 | }
55 | };
56 |
57 | export default function AuthenticationNavigation() {
58 | const dispatch = useAppDispatch();
59 | const isLoggedIn = useAppSelector((state: RootState) => loggedIn(state));
60 | const router = useRouter();
61 |
62 | const logoutUser = () => {
63 | dispatch(logout());
64 | router.push(redirectRoute);
65 | };
66 |
67 | return (
68 |
96 | );
97 | }
98 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/components/moderation/CheckState.tsx:
--------------------------------------------------------------------------------
1 | import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/outline";
2 |
3 | interface CheckStateProps {
4 | check: boolean;
5 | }
6 |
7 | export default function CheckState(props: CheckStateProps) {
8 | return (
9 |
10 | {props.check ? (
11 |
15 | ) : (
16 |
20 | )}
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/components/moderation/CheckToggle.tsx:
--------------------------------------------------------------------------------
1 | import { Switch } from "@headlessui/react";
2 | import { useState } from "react";
3 |
4 | interface CheckToggleProps {
5 | check: boolean;
6 | onClick: any;
7 | }
8 |
9 | export default function CheckToggle(props: CheckToggleProps) {
10 | const [enabled, setEnabled] = useState(props.check);
11 |
12 | return (
13 | props.onClick()}
18 | >
19 | Use setting
20 |
24 |
31 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/components/moderation/CreateUser.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { apiAuth } from "../../lib/api";
4 | import { generateUUID } from "../../lib/utilities";
5 | import { IUserProfileCreate } from "../../lib/interfaces";
6 | import { useForm } from "react-hook-form";
7 | import { useAppDispatch, useAppSelector } from "../../lib/hooks";
8 | import { refreshTokens, token } from "../../lib/slices/tokensSlice";
9 | import { RootState } from "../../lib/store";
10 | import { addNotice } from "../../lib/slices/toastsSlice";
11 |
12 | export default function CreateUser() {
13 | const dispatch = useAppDispatch();
14 | const accessToken = useAppSelector((state: RootState) => token(state));
15 | const state = useAppSelector((state: RootState) => state);
16 |
17 | const { register, handleSubmit } = useForm();
18 |
19 | // @ts-ignore
20 | async function submit(values: any) {
21 | if (values.email) {
22 | await dispatch(refreshTokens());
23 | const data: IUserProfileCreate = {
24 | email: values.email,
25 | password: generateUUID(),
26 | fullName: values.fullName ? values.fullName : "",
27 | };
28 | try {
29 | const res = await apiAuth.createUserProfile(accessToken, data);
30 | if (!res.id) throw "Error";
31 | dispatch(
32 | addNotice({
33 | title: "User created",
34 | content:
35 | "An email has been sent to the user with their new login details.",
36 | }),
37 | );
38 | } catch {
39 | dispatch(
40 | addNotice({
41 | title: "Update error",
42 | content: "Invalid request.",
43 | icon: "error",
44 | }),
45 | );
46 | }
47 | }
48 | }
49 |
50 | return (
51 |
101 | );
102 | }
103 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/components/moderation/ToggleActive.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { apiAuth } from "../../lib/api";
4 | import { IUserProfileUpdate } from "../../lib/interfaces";
5 | import CheckToggle from "./CheckToggle";
6 | import { useState } from "react";
7 | import { useAppDispatch, useAppSelector } from "../../lib/hooks";
8 | import { refreshTokens, token } from "../../lib/slices/tokensSlice";
9 | import { addNotice } from "../../lib/slices/toastsSlice";
10 | import { RootState } from "../../lib/store";
11 |
12 | interface ToggleActiveProps {
13 | email: string;
14 | check: boolean;
15 | }
16 |
17 | export default function ToggleActive(props: ToggleActiveProps) {
18 | const dispatch = useAppDispatch();
19 | const accessToken = useAppSelector((state: RootState) => token(state));
20 |
21 | const [enabled, setEnabled] = useState(props.check);
22 |
23 | async function submit() {
24 | await dispatch(refreshTokens());
25 | const data: IUserProfileUpdate = {
26 | email: props.email,
27 | is_active: !props.check,
28 | };
29 | try {
30 | const res = await apiAuth.toggleUserState(accessToken, data);
31 | if (!res.msg) throw res;
32 | } catch (results) {
33 | dispatch(
34 | addNotice({
35 | title: "Update error",
36 | //@ts-ignore
37 | content: results?.msg ?? "Invalid request.",
38 | icon: "error",
39 | }),
40 | );
41 | setEnabled(props.check);
42 | }
43 | }
44 |
45 | return ;
46 | }
47 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/components/moderation/ToggleMod.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { apiAuth } from "../../lib/api";
4 | import { IUserProfileUpdate } from "../../lib/interfaces";
5 | import CheckToggle from "./CheckToggle";
6 | import { useAppDispatch, useAppSelector } from "../../lib/hooks";
7 | import { RootState } from "../../lib/store";
8 | import { useState } from "react";
9 | import { refreshTokens, token } from "../../lib/slices/tokensSlice";
10 | import { addNotice } from "../../lib/slices/toastsSlice";
11 |
12 | interface ToggleModProps {
13 | email: string;
14 | check: boolean;
15 | }
16 |
17 | export default function ToggleMod(props: ToggleModProps) {
18 | const dispatch = useAppDispatch();
19 | const accessToken = useAppSelector((state: RootState) => token(state));
20 |
21 | const [enabled, setEnabled] = useState(props.check);
22 |
23 | async function submit() {
24 | await dispatch(refreshTokens());
25 | const data: IUserProfileUpdate = {
26 | email: props.email,
27 | is_superuser: !props.check,
28 | };
29 | try {
30 | const res = await apiAuth.toggleUserState(accessToken, data);
31 | if (!res.msg) throw res;
32 | } catch (results) {
33 | dispatch(
34 | addNotice({
35 | title: "Update error",
36 | //@ts-ignore
37 | content: results?.msg ?? "Invalid request.",
38 | icon: "error",
39 | }),
40 | );
41 | setEnabled(props.check);
42 | }
43 | }
44 |
45 | return ;
46 | }
47 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/components/moderation/UserTable.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { apiAuth } from "../../lib/api";
4 | import { IUserProfile } from "../../lib/interfaces";
5 | import CheckState from "./CheckState";
6 | import ToggleActive from "./ToggleActive";
7 | import ToggleMod from "./ToggleMod";
8 | import { useAppDispatch, useAppSelector } from "../../lib/hooks";
9 | import { RootState } from "../../lib/store";
10 | import { useEffect, useState } from "react";
11 | import { refreshTokens, token } from "../../lib/slices/tokensSlice";
12 | import { addNotice } from "../../lib/slices/toastsSlice";
13 |
14 | const renderUserProfiles = (userProfiles: IUserProfile[]) => {
15 | return userProfiles.map((profile) => (
16 |
17 |
18 | {profile.fullName}
19 |
20 | - Email
21 | - {profile.email}
22 | - Validated
23 | -
24 |
25 |
26 |
27 | |
28 |
29 | {profile.email}
30 | |
31 |
32 |
33 | |
34 |
35 |
36 | |
37 |
38 |
39 | |
40 |
41 | ));
42 | };
43 |
44 | export default function UserTable() {
45 | const dispatch = useAppDispatch();
46 | const accessToken = useAppSelector((state: RootState) => token(state));
47 |
48 | const [userProfiles, setUserProfiles] = useState([] as IUserProfile[]);
49 |
50 | async function getAllUsers() {
51 | await dispatch(refreshTokens());
52 | try {
53 | const res = await apiAuth.getAllUsers(accessToken);
54 | if (res && res.length) setUserProfiles(res);
55 | } catch {
56 | dispatch(
57 | addNotice({
58 | title: "User Fetch Issue",
59 | content:
60 | "Failed to fetch all users, please check logged in permissions",
61 | icon: "error",
62 | }),
63 | );
64 | }
65 | }
66 |
67 | useEffect(() => {
68 | async function fetchUsers() {
69 | await getAllUsers();
70 | }
71 |
72 | fetchUsers();
73 | }, []); // eslint-disable-line react-hooks/exhaustive-deps
74 |
75 | return (
76 |
77 |
78 |
79 |
80 |
84 | Name
85 | |
86 |
90 | Email
91 | |
92 |
96 | Validated
97 | |
98 |
102 | Active
103 | |
104 |
108 | Moderator
109 | |
110 |
111 |
112 |
113 | {renderUserProfiles(userProfiles)}
114 |
115 |
116 |
117 | );
118 | }
119 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/components/settings/ValidateEmailButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { AtSymbolIcon } from "@heroicons/react/24/outline";
4 | import { useAppDispatch } from "../../lib/hooks";
5 | import { sendEmailValidation } from "../../lib/slices/authSlice";
6 |
7 | export default function ValidateEmailButton() {
8 | const dispatch = useAppDispatch();
9 |
10 | async function submit() {
11 | await dispatch(sendEmailValidation());
12 | }
13 |
14 | return (
15 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/content/blog/20160708-theranos-and-elitism.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Theranos and the elitist belief in magical thinking
3 | description: "If an African leader stood up at a meeting of European investors and declared that his country’s agricultural success could be attributed to traditional muthi, he would be regarded with an embarrassed sigh."
4 | author: Gavin Chait
5 | publishedAt: 2016-07-08
6 | categories: science, superstition
7 | ---
8 |
9 | # Theranos and the elitist belief in magical thinking
10 |
11 | If an African leader stood up at a meeting of European investors and declared that his country’s agricultural success could be attributed to traditional _muthi_, he would be regarded with an embarrassed sigh.
12 |
13 | Except when it’s the British aristocrat, Prince Charles, and he’s talking about using homeopathy to treat his cows, then he’s treated with polite applause.
14 |
15 | Nowhere is that hypocrisy more visible than in the story of President Yahyah Jammeh of Gambia who claims his homeopathy can cure AIDS. He is supported by Ainsworths, a homeopathic dealer which operates under a royal seal of appointment from Prince Charles.
16 |
17 | Superstition is alive and well in the West, only instead of skins and furs, it wears a white lab coat and attempts to look respectable.
18 |
19 | There are two ways in which this is having a destructive effect on humanity.
20 |
21 | The first is in adaptation to Climate Change.
22 |
23 | There is near universal scientific support for the theory that global warming is real and caused by people. Greenpeace and other pressure groups are in full accord with scientific thinking here.
24 |
25 | Scientists also have near universal agreement on the benefits of genetically modified organisms. Greenpeace and other pressure groups refuse to accept scientific thinking on this topic, promoting the woolly world of ‘organic’ instead.
26 |
27 | Their thinking can be summarised as being that climate change confirms Greenpeace’s bias against large corporations as the cause of all evil, while accepting genetically modified crops as being healthy would contradict that belief, since it demonstrates that large corporations are key to solving the world’s problems.
28 |
29 | Each could be true, but Greenpeace insists that corporations can only be evil, hence their loathing of GM.
30 |
31 | This has become so worrying – being that it denies life-saving crops to African countries already suffering under drought and famine – that more than 109 Nobel Prize- winners have signed an open letter demanding that Greenpeace end their campaign against GM foods: “Scientific and regulatory agencies around the world have repeatedly and consistently found crops and foods improved through biotechnology to be as safe as, if not safer than those derived from any other method of production.”
32 |
33 | Magical thinking against real and working science prevents access for those people who would most benefit from it.
34 |
35 | The second destructive outcome is where magical thinking, dressed up in scientific garb, undermines real science.
36 |
37 | The worst and most recent of many such scandals involves Theranos, a US-based medical laboratory service. Theranos promised investors and medical professionals an end to painful and unpleasant needle-based blood specimen collection, and a world of cheap and easily available medical tests.
38 |
39 | The ‘secret’ was their heavily secret blood-testing device called Edison. Instead of traditional venepuncture (a needle, to the rest of us), they used a few drops of blood from a finger-stick puncture. Forget that actual medical professionals and scientists pointed out that such a small amount of blood, drawn from a peripheral part of the body, would produce wildly varying results no matter how clever the diagnostic machine, investors hurled $400 million at it.
40 |
41 | By 2014, the company – and its charming, blonde, blue-eyed CEO Elizabeth Holmes – was estimated to be worth $9 billion.
42 |
43 | Eventually, at the top of the hype train, the Food and Drug Administration began to look into the company, pointing out that there was almost no quality control and that - far from using some secret technology – most tests were being run on traditional devices.
44 |
45 | Theranos was forced to void all their test results. One of their main labs has been shut down. Last week Holmes was banned from operating any lab for two years.
46 |
47 | Along the way, Walmart fell for the hype and entered into a costly partnership, and hundreds of normally reasonable investors have lost their shirts. Theranos is now worth nothing.
48 |
49 | Magical thinking is not science and, given the range of challenges humanity faces, it’s time we took it a bit more seriously.
50 |
51 | ---
52 |
53 | _© Gavin Chait 2016. All rights reserved._
54 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/content/blog/20160721-lament-for-the-auther.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: A lament for the author
3 | description: "‘I’ve got a book out,’ says me hopefully, my hands twisted on the keyboard."
4 | author: Gavin Chait
5 | publishedAt: 2016-07-21
6 | categories: publishing
7 | ---
8 |
9 | # A lament for the author
10 |
11 | ‘I’ve got a book out,’ says me hopefully, my hands twisted on the keyboard.
12 |
13 | Three years ago, I took a month off and set out to start and finish writing a novel. A science fiction novel set in Nigeria, no less.
14 |
15 | The first month allowed me to knock out 60,000 words. It took the next 18 months to make time to expand and polish that core.
16 |
17 | ‘Nobody knows anything,’ said William Goldman in describing Hollywood’s ability to pick winners (and their investors’ regular ability to produce financial disasters).
18 |
19 | I’ve wanted to write novels since before I was in my teens, but it always seemed overly intimidating. Swatting out a few short articles a week is one thing. Sitting down and committing to produce 100,000 words is quite another.
20 |
21 | The not knowing is also about not knowing what’s involved in producing the thing, let alone whether it will be successful.
22 |
23 | And the economics are fairly harsh. If you want to make, for example, R20 from each book, you’re going to have to sell tens of thousands every year before you can quit your day-job.
24 |
25 | In exchange, you need to commit months of time unpaid in the insecure hope that what you produce is – at the very least – read.
26 |
27 | Each day an estimated thousand to two thousand books are published, adding to the 31 million paperbacks or the 3.1 million ebooks already available on Amazon.com.
28 |
29 | Maybe you’ve heard of the self-publishing phenomenon, and of the miraculous stories of people like Hugh Howey who self-published his Wool and Sand series and became a best-seller, or Mark Dawson’s series about an assassin which earns him $450,000 a year?
30 |
31 | Sadly, out of the well over half a million new novels published every year, very few are going to make that sort of money. For most writers, scribbling in any spare time they can manage, they are unlikely to experience that kind of success.
32 |
33 | There are numerous lightning strikes you need to navigate, many lottery tickets which need to be won in sequence before the final lottery of which ‘nobody knows anything’: why does one book become a best-seller but another, similar book, goes read only by close friends and relatives of the author?
34 |
35 | You can throw runes and try divining their meaning; is it price? Is it the cover? How about the day of week or time of day when it is launched? Summer or winter?
36 |
37 | George RR Martin published his first novel in 1983, but it wasn’t till 1996 that he released ‘A Game of Thrones’, and it wasn’t till the fourth in that series – 2005’s ‘A Feast for Crows’ – that he began to achieve success. The HBO ‘Game of Thrones’ adaptation of his novels has made him world famous.
38 |
39 | At the other end is Andy Weir who published his first novel, ‘The Martian’, in 2011, achieved runaway success immediately, and saw it turned into a madly successful movie in 2015.
40 |
41 | Writers can achieve success instantly, languish in obscurity and then achieve success, or languish in obscurity indefinitely.
42 |
43 | Figuring out what and who will connect is, well, you know already.
44 |
45 | Weirdly, the same is true of newspaper columns I’ve written. I’ve had relatively obscure topics explode my inbox, and others where I thought it would result in some controversy result in the gentle sound of crickets at midnight.
46 |
47 | Despite all the uncertainty - and the supposed destruction of mainstream publishers - 60% of all commercial sales still accrue to the big six publishers. In the US, that’s an astonishing $27 billion industry total a year.
48 |
49 | I hope you’re interested in reading about how my hero escapes from an orbital prison, survives the subsequent fall and crash-landing in a small Nigerian village, and escapes the interest of a local warlord.
50 |
51 | Continues me, ‘It’ll be out on Friday. Like a real book, with pages and everything. It’s called [“Lament for the Fallen”](https://gavinchait.com/lament-for-the-fallen/), go buy it.’
52 |
53 | ---
54 |
55 | _© Gavin Chait 2016. All rights reserved._
56 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/content/blog/20170203-summer-of-99.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: The summer of '99
3 | description: "I remember what it was like being a student after the fall of the Berlin Wall, the collapse of Communism, the release of Nelson Mandela, and the ignominious end of Apartheid."
4 | author: Gavin Chait
5 | publishedAt: 2017-02-03
6 | categories: hope, equality, liberty
7 | ---
8 |
9 | # The summer of ‘99
10 |
11 | I remember what it was like being a student after the fall of the Berlin Wall, the collapse of Communism, the release of Nelson Mandela, and the ignominious end of Apartheid.
12 |
13 | I remember the blue clarity of its summer skies. The way, driving from the university into the city, the green slopes of Table Mountain would open to the vast bright glare of the ocean. The smell of fynbos and roar of cicadas and of hope so tangible it felt as if everyone was bouncing as they walked.
14 |
15 | An entire generation raised on the notion that evil could be vanquished, that the brutalised could be made whole again, and that anything was possible.
16 |
17 | Some of us dedicated our lives to building Nelson Mandela’s vision of a ‘new South Africa’, working in the townships building houses, bringing healthcare and education, or creating jobs. Others, like Elon Musk and Mark Shuttleworth, headed to the US and became billionaires.
18 |
19 | It is symbolic of the time that social confidence – belief in the possible – was so high that some of the largest companies founded in recent years were all immigrants to the US.
20 |
21 | eBay founded by Frenchman Pierre Omidyar. Google founded by Russian Sergey Brin. Yahoo founded by Taiwanese Jerry Yang. And there are countless others, less well known but equally as dynamic and exciting.
22 |
23 | So confident was the period that Francis Fukuyama could pronounce the ‘end of history’, with an important caveat. He worried we would forget what it had cost us to achieve our freedom, and we would chafe with resentment as the established pecking order was disrupted. And then the despots would return.
24 |
25 | I remember when that darkness began to loom. It was 9 July 2000 when Nkosi Johnson rose to address the 13th International AIDS Conference in Durban.
26 |
27 | "Care for us and accept us - we are all human beings. We are normal. We have hands. We have feet. We can walk, we can talk, we have needs just like everyone else - don't be afraid of us - we are all the same!"
28 |
29 | Powerful words. Thabo Mbeki scowled in disgust and walked out.
30 |
31 | If you had to pick a moment when the forces of truth and science and knowledge were cast aside in favour of lies, ‘fake news’, bigotry and superstition, it was that moment. Thabo Mbeki deserves nothing but scorn and contempt.
32 |
33 | And I remember the sense of our world being utterly destroyed on 11 September 2001 when hatred and scorn emerged from the sixteenth century, and liberalism gave way to mutual suspicion.
34 |
35 | There was a brief window when being young was not about crisis and rebellion, but about hope and building.
36 |
37 | It would be nice to say, well, that’s just white racism. It isn’t only. There are violent suppressions of liberal values in almost every country. From Rodrigo Duterte’s massacre of alleged drug-dealers and users in the Philippines, to Recep Erdoğan’s arrest of tens of thousands of ordinary people in the aftermath of a coup attempt in Turkey. The African Union has backed mass withdrawal of all African countries from the International Criminal Court.
38 |
39 | This is the return of politics and history with a vengeance.
40 |
41 | But the summer of ’99 existed. It could exist again.
42 |
43 | The first lesson of the fall of the Berlin Wall is that sustained mass protest works (with the important proviso that – with a nod to Egypt of 2013, China of 1989, and South Africa of 1960 – any government willing to massacre its own protesting citizens can impose anything it likes).
44 |
45 | The second is that you should never stop building and believing. It is easy to run away, more difficult and dangerous to run towards.
46 |
47 | We need people who dream, who build and are willing to share those dreams and ambitions. Start businesses anyway. Organise protests anyway. Work together across the things that divide us anyway.
48 |
49 | Because everyone should get to have that summer.
50 |
51 | ---
52 |
53 | _© Gavin Chait 2017. All rights reserved._
54 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/content/privacy.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Your privacy
3 | description: "We at [Website Name] value your privacy as much as we value a cheap joke."
4 | navigation: false
5 | ---
6 |
7 | # Your privacy and rights
8 |
9 | Welcome to [Website Name], the online version of a hallucination induced by eating too much cheese. Here's what you can expect when you use our website:
10 |
11 | ## Privacy policy
12 |
13 | - We'll collect every piece of personal information you have, including your name, address, phone number, email, social security number, credit card details, and your mother's maiden name. We'll also peek into your browser history, your text messages, and your dreams (if we can find the right mushrooms).
14 | - We promise to use your data for twisted and bizarre purposes only, like cloning you and making you fight your clone to the death, using your DNA to create a race of superhumans, or summoning a demon that looks like your grandma.
15 | - We'll use your information to spam you with ads that are so surreal and disorienting, you'll think you're trapped in a Salvador Dali painting. We'll also use it to mess with your mind, make you question reality, and possibly even inspire you to start a cult (which we'll join, of course).
16 | - We'll store your data in a realm of pure chaos and madness, guarded by an army of chimeras, goblins, and robots that have gone rogue. We'll also share your data with our interdimensional overlords, who are always hungry for new sources of entertainment.
17 | - We'll use cookies to track your every move online, and we'll use that information to create a digital avatar of you that's even weirder and more unpredictable than the real you. We'll also use cookies to play pranks on you, like making your cursor turn into a banana or making your keyboard explode (don't worry, it's just a harmless little explosion).
18 |
19 | ## GDPR
20 |
21 | We don't care about GDPR or any other earthly laws. Our website operates in a dimension beyond your feeble human concepts of order and justice. If you try to sue us, we'll just laugh and summon a horde of poltergeists to haunt you for eternity.
22 |
23 | ## Liability
24 |
25 | By using our website, you agree to relinquish all control over your sanity, your identity, and your soul. You acknowledge that our website is a portal to a universe of madness and mayhem, and that you are entering at your own risk. We're not liable for any psychological, spiritual, or metaphysical damage that may result from using our website. But hey, at least you'll have a good story to tell the angels (or the demons, depending on how things turn out).
26 |
27 | Thank you for choosing [Website Name], where the rules of logic and reality are optional, and the nightmares are free of charge.
28 |
29 | \_(TODO)
30 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mongodb-labs/full-stack-fastapi-mongodb/ffbc2ca97388f7f90b4e24e808c26b67d5f783cf/{{cookiecutter.project_slug}}/frontend/app/favicon.ico
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import "./assets/css/main.css";
2 | import type { Metadata } from "next";
3 | import Navigation from "./components/Navigation";
4 | import Notification from "./components/Notification";
5 | import ReduxProvider from "./lib/reduxProvider";
6 | import Footer from "./components/Footer";
7 |
8 | export const metadata: Metadata = {
9 | title: "FastAPI/React starter stack",
10 | description: "Accelerate your next web development project",
11 | };
12 |
13 | export default function RootLayout({
14 | children,
15 | }: {
16 | children: React.ReactNode;
17 | }) {
18 | return (
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | {children}
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/lib/api/core.ts:
--------------------------------------------------------------------------------
1 | export const apiCore = {
2 | url: process.env.NEXT_PUBLIC_API_URL,
3 | headers(token: string) {
4 | return {
5 | "Cache-Control": "no-cache",
6 | Authorization: `Bearer ${token}`,
7 | "Content-Type": "application/json",
8 | };
9 | },
10 | };
11 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/lib/api/index.ts:
--------------------------------------------------------------------------------
1 | import { apiCore } from "./core";
2 | import { apiAuth } from "./auth";
3 | import { apiService } from "./services";
4 |
5 | export { apiCore, apiAuth, apiService };
6 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/lib/api/services.ts:
--------------------------------------------------------------------------------
1 | import { ISendEmail, IMsg } from "../interfaces";
2 | import { apiCore } from "./core";
3 |
4 | export const apiService = {
5 | // USER CONTACT MESSAGE
6 | async postEmailContact(data: ISendEmail) {
7 | const res = await fetch(`${apiCore.url}/service/contact`, {
8 | method: "POST",
9 | body: JSON.stringify(data),
10 | });
11 |
12 | return (await res.json()) as IMsg;
13 | },
14 | };
15 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/lib/hooks.ts:
--------------------------------------------------------------------------------
1 | import { useDispatch, useSelector } from "react-redux";
2 | import type { TypedUseSelectorHook } from "react-redux";
3 | import type { RootState, AppDispatch } from "./store";
4 |
5 | export const useAppDispatch: () => AppDispatch = useDispatch;
6 | export const useAppSelector: TypedUseSelectorHook = useSelector;
7 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/lib/interfaces/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | IUserProfile,
3 | IUserProfileUpdate,
4 | IUserProfileCreate,
5 | IUserOpenProfileCreate,
6 | } from "./profile";
7 |
8 | import {
9 | ITokenResponse,
10 | IWebToken,
11 | INewTOTP,
12 | IEnableTOTP,
13 | ISendEmail,
14 | IMsg,
15 | INotification,
16 | IErrorResponse,
17 | } from "./utilities";
18 |
19 | // https://stackoverflow.com/a/64782482/295606
20 | interface IKeyable {
21 | [key: string]: any | any[];
22 | }
23 |
24 | export type {
25 | IKeyable,
26 | IUserProfile,
27 | IUserProfileUpdate,
28 | IUserProfileCreate,
29 | IUserOpenProfileCreate,
30 | ITokenResponse,
31 | IWebToken,
32 | INewTOTP,
33 | IEnableTOTP,
34 | ISendEmail,
35 | IMsg,
36 | INotification,
37 | IErrorResponse,
38 | };
39 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/lib/interfaces/profile.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable camelcase */
2 | export interface IUserProfile {
3 | id: string;
4 | email: string;
5 | email_validated: boolean;
6 | is_active: boolean;
7 | is_superuser: boolean;
8 | fullName: string;
9 | password: boolean;
10 | totp: boolean;
11 | }
12 |
13 | export interface IUserProfileUpdate {
14 | email?: string;
15 | fullName?: string;
16 | original?: string;
17 | password?: string;
18 | is_active?: boolean;
19 | is_superuser?: boolean;
20 | }
21 |
22 | export interface IUserProfileCreate {
23 | email: string;
24 | fullName?: string;
25 | password?: string;
26 | is_active?: boolean;
27 | is_superuser?: boolean;
28 | }
29 |
30 | export interface IUserOpenProfileCreate {
31 | email: string;
32 | fullName?: string;
33 | password: string;
34 | }
35 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/lib/interfaces/utilities.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable camelcase */
2 |
3 | export interface ITokenResponse {
4 | access_token: string;
5 | refresh_token: string;
6 | token_type: string;
7 | }
8 |
9 | export interface IWebToken {
10 | claim: string;
11 | }
12 |
13 | export interface INewTOTP {
14 | secret?: string;
15 | key: string;
16 | uri: string;
17 | }
18 |
19 | export interface IEnableTOTP {
20 | claim: string;
21 | uri: string;
22 | password?: string;
23 | }
24 |
25 | export interface ISendEmail {
26 | email: string;
27 | subject: string;
28 | content: string;
29 | }
30 |
31 | export interface IMsg {
32 | msg: string;
33 | }
34 |
35 | export interface INotification {
36 | uid?: string;
37 | title: string;
38 | content: string;
39 | icon?: "success" | "error" | "information";
40 | showProgress?: boolean;
41 | }
42 |
43 | export interface IErrorResponse {
44 | message: string;
45 | code: number;
46 | }
47 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/lib/reduxProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { store } from "./store";
4 | import { Provider } from "react-redux";
5 | import { persistStore } from "redux-persist";
6 |
7 | persistStore(store); // persist the store
8 |
9 | export default function ReduxProvider(props: React.PropsWithChildren) {
10 | return {props.children};
11 | }
12 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/lib/slices/toastsSlice.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Dispatch,
3 | PayloadAction,
4 | createAsyncThunk,
5 | createSlice,
6 | } from "@reduxjs/toolkit";
7 | import { INotification } from "../interfaces";
8 | import { generateUUID } from "../utilities";
9 | import { RootState } from "../store";
10 |
11 | interface ToastsState {
12 | notifications: INotification[];
13 | }
14 |
15 | const initialState: ToastsState = {
16 | notifications: [],
17 | };
18 |
19 | export const toastsSlice = createSlice({
20 | name: "toasts",
21 | initialState,
22 | reducers: {
23 | addNotice: (state: ToastsState, action: PayloadAction) => {
24 | action.payload.uid = generateUUID();
25 | if (!action.payload.icon) action.payload.icon = "success";
26 | state.notifications.push(action.payload);
27 | },
28 | removeNotice: (
29 | state: ToastsState,
30 | action: PayloadAction,
31 | ) => {
32 | state.notifications = state.notifications.filter(
33 | (notice) => notice.uid !== action.payload.uid,
34 | );
35 | },
36 | deleteNotices: () => {
37 | return initialState;
38 | },
39 | },
40 | });
41 |
42 | export const { addNotice, removeNotice, deleteNotices } = toastsSlice.actions;
43 |
44 | export const timeoutNotice =
45 | (payload: INotification, timeout: number = 2000) =>
46 | async (dispatch: Dispatch) => {
47 | await new Promise((resolve) => {
48 | setTimeout(() => {
49 | dispatch(removeNotice(payload));
50 | resolve(true);
51 | }, timeout);
52 | });
53 | };
54 |
55 | export const first = (state: RootState) =>
56 | state.toasts.notifications.length > 0 && state.toasts.notifications[0];
57 | export const notices = (state: RootState) => state.toasts.notifications;
58 |
59 | export default toastsSlice.reducer;
60 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/lib/storage.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import createWebStorage from "redux-persist/lib/storage/createWebStorage";
4 |
5 | const createNoopStorage = () => {
6 | return {
7 | getItem(_key: any) {
8 | return Promise.resolve(null);
9 | },
10 | setItem(_key: any, value: any) {
11 | return Promise.resolve(value);
12 | },
13 | removeItem(_key: any) {
14 | return Promise.resolve();
15 | },
16 | };
17 | };
18 |
19 | const storage =
20 | typeof window !== "undefined"
21 | ? createWebStorage("local")
22 | : createNoopStorage();
23 |
24 | export default storage;
25 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/lib/store.ts:
--------------------------------------------------------------------------------
1 | import { combineReducers, configureStore } from "@reduxjs/toolkit";
2 | import {
3 | FLUSH,
4 | PAUSE,
5 | REHYDRATE,
6 | PERSIST,
7 | PURGE,
8 | REGISTER,
9 | persistReducer,
10 | } from "redux-persist";
11 | import authReducer from "./slices/authSlice";
12 | import toastsReducer from "./slices/toastsSlice";
13 | import tokensReducer from "./slices/tokensSlice";
14 | import storage from "./storage";
15 |
16 | const reducers = combineReducers({
17 | auth: authReducer,
18 | toasts: toastsReducer,
19 | tokens: tokensReducer,
20 | });
21 |
22 | const persistConfig = {
23 | key: "root",
24 | storage,
25 | };
26 |
27 | const persistedReducer = persistReducer(persistConfig, reducers);
28 |
29 | export const store = configureStore({
30 | reducer: persistedReducer,
31 | middleware: (getDefaultMiddleware) =>
32 | getDefaultMiddleware({
33 | serializableCheck: {
34 | ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
35 | },
36 | }),
37 | });
38 |
39 | // Infer the `RootState` and `AppDispatch` types from the store itself
40 | export type RootState = ReturnType;
41 | // Inferred type: {auth: AuthState, toasts: ToastsState, tokens: TokensState}
42 | export type AppDispatch = typeof store.dispatch;
43 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/lib/utilities/generic.ts:
--------------------------------------------------------------------------------
1 | import { Buffer } from "buffer";
2 |
3 | function generateUUID(): string {
4 | // Reference: https://stackoverflow.com/a/2117523/709884
5 | // And: https://stackoverflow.com/a/61011303/295606
6 | return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (s) => {
7 | const c = Number.parseInt(s, 10);
8 | return (
9 | c ^
10 | (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
11 | ).toString(16);
12 | });
13 | }
14 |
15 | function isValidHttpUrl(urlString: string) {
16 | // https://stackoverflow.com/a/43467144
17 | let url;
18 | try {
19 | url = new URL(urlString);
20 | } catch (_) {
21 | return false;
22 | }
23 | return url.protocol === "http:" || url.protocol === "https:";
24 | }
25 |
26 | function getKeyByValue(object: any, value: any) {
27 | // https://stackoverflow.com/a/28191966/295606
28 | return Object.keys(object).find((key) => object[key] === value);
29 | }
30 |
31 | function getTimeInSeconds(): number {
32 | // https://stackoverflow.com/a/3830279/295606
33 | return Math.floor(new Date().getTime() / 1000);
34 | }
35 |
36 | function tokenExpired(token: string) {
37 | // https://stackoverflow.com/a/60758392/295606
38 | // https://stackoverflow.com/a/71953677/295606
39 | const expiry = JSON.parse(
40 | Buffer.from(token.split(".")[1], "base64").toString(),
41 | ).exp;
42 | return getTimeInSeconds() >= expiry;
43 | }
44 |
45 | function tokenParser(token: string) {
46 | return JSON.parse(Buffer.from(token.split(".")[1], "base64").toString());
47 | }
48 |
49 | const siteName = "{{ cookiecutter.project_name }}";
50 |
51 | export {
52 | generateUUID,
53 | getTimeInSeconds,
54 | tokenExpired,
55 | getKeyByValue,
56 | isValidHttpUrl,
57 | tokenParser,
58 | siteName,
59 | };
60 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/lib/utilities/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | generateUUID,
3 | getTimeInSeconds,
4 | tokenExpired,
5 | getKeyByValue,
6 | isValidHttpUrl,
7 | tokenParser,
8 | } from "./generic";
9 | import { readableDate } from "./textual";
10 | import { tokenIsTOTP } from "./totp";
11 |
12 | export {
13 | generateUUID,
14 | getTimeInSeconds,
15 | tokenExpired,
16 | getKeyByValue,
17 | isValidHttpUrl,
18 | tokenParser,
19 | readableDate,
20 | tokenIsTOTP,
21 | };
22 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/lib/utilities/posts.ts:
--------------------------------------------------------------------------------
1 | import fs from "fs";
2 | import path from "path";
3 | import matter from "gray-matter";
4 | import { remark } from "remark";
5 | import html from "remark-html";
6 | import remarkGfm from "remark-gfm";
7 | import remarkToc from "remark-toc";
8 |
9 | const postsDirectory = path.join(process.cwd(), "app/content/blog");
10 |
11 | /**
12 | * Get the data of all posts in sorted order by date
13 | * @return {List[Object]} Returns an array that looks like this:
14 | [
15 | {
16 | id: 'ssg-ssr',
17 | title: 'When to Use Static Generation v.s. Server-side Rendering',
18 | date: '2020-01-01'
19 | },
20 | {
21 | id: 'pre-rendering',
22 | title: 'Two Forms of Pre-rendering',
23 | date: '2020-01-02'
24 | }
25 | ]
26 | */
27 | export function getSortedPostsData() {
28 | // Get file names under /posts
29 | const fileNames = fs.readdirSync(postsDirectory); // [ 'pre-rendering.md', 'ssg-ssr.md' ]
30 |
31 | // Get the data from each file
32 | const allPostsData = fileNames.map((filename) => {
33 | // Remove ".md" from file name to get id
34 | const id = filename.replace(/\.md$/, ""); // id = 'pre-rendering', 'ssg-ssr'
35 |
36 | // Read markdown file as string
37 | const fullPath = path.join(postsDirectory, filename);
38 | // /Users/ef/Desktop/nextjs-blog/posts/pre-rendering.md
39 | const fileContents = fs.readFileSync(fullPath, "utf8"); // .md string content
40 |
41 | // Use gray-matter to parse the post metadata section
42 | const matterResult = matter(fileContents);
43 |
44 | const categories: string[] = matterResult.data.categories.split(",");
45 |
46 | return {
47 | id,
48 | ...(matterResult.data as {
49 | publishedAt: string;
50 | title: string;
51 | author: string;
52 | description: string;
53 | }),
54 | categories,
55 | };
56 | });
57 |
58 | // Sort posts by date and return
59 | return allPostsData.sort((a, b) => {
60 | if (a.publishedAt < b.publishedAt) {
61 | return 1;
62 | } else {
63 | return -1;
64 | }
65 | });
66 | }
67 |
68 | // ------------------------------------------------
69 | // GET THE IDs OF ALL POSTS FOR THE DYNAMIC ROUTING
70 | /*
71 | Returns an array that looks like this:
72 | [
73 | {
74 | params: {
75 | id: 'ssg-ssr'
76 | }
77 | },
78 | {
79 | params: {
80 | id: 'pre-rendering'
81 | }
82 | }
83 | ]
84 | */
85 |
86 | export function getAllPostIds() {
87 | const fileNames = fs.readdirSync(postsDirectory);
88 |
89 | return fileNames.map((fileName) => {
90 | return {
91 | params: {
92 | id: fileName.replace(/\.md$/, ""),
93 | },
94 | };
95 | });
96 | }
97 |
98 | // The returned array must have the params key otherwise `getStaticPaths` will fail
99 |
100 | // --------------------------------
101 | // GET THE DATA OF A SINGLE POST FROM THE ID
102 | export async function getPostData(
103 | id: string,
104 | directory: string = postsDirectory,
105 | ) {
106 | const fullPath = path.join(directory, `${id}.md`);
107 | const fileContents = fs.readFileSync(fullPath, "utf8");
108 |
109 | // Use gray-matter to parse the post metadata section
110 | const matterResult = matter(fileContents);
111 |
112 | // Use remark to convert markdown into HTML string
113 | const processedContent = await remark()
114 | .use(html)
115 | .use(remarkGfm)
116 | .use(remarkToc)
117 | .process(matterResult.content);
118 | const content = processedContent.toString();
119 |
120 | // Combine the data with the id
121 | return {
122 | id,
123 | content,
124 | ...(matterResult.data as {
125 | publishedAt: string;
126 | title: string;
127 | author: string;
128 | description: string;
129 | }),
130 | };
131 | }
132 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/lib/utilities/textual.ts:
--------------------------------------------------------------------------------
1 | function readableDate(term: Date | string, showYear: boolean = true) {
2 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleDateString
3 | // https://stackoverflow.com/a/66590756/295606
4 | // https://stackoverflow.com/a/67196206/295606
5 | const readable = term instanceof Date ? term : new Date(term);
6 | const day = readable.toLocaleDateString("en-UK", { day: "numeric" });
7 | const month = readable.toLocaleDateString("en-UK", { month: "short" });
8 | if (showYear) {
9 | const year = readable.toLocaleDateString("en-UK", { year: "numeric" });
10 | return `${day} ${month} ${year}`;
11 | }
12 | return `${day} ${month}`;
13 | }
14 |
15 | export { readableDate };
16 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/lib/utilities/totp.ts:
--------------------------------------------------------------------------------
1 | import { tokenParser } from "./generic";
2 |
3 | function tokenIsTOTP(token: string) {
4 | // https://stackoverflow.com/a/60758392/295606
5 | // https://stackoverflow.com/a/71953677/295606
6 | const obj = tokenParser(token);
7 | if (obj.hasOwnProperty("totp")) return obj.totp;
8 | else return false;
9 | }
10 |
11 | export { tokenIsTOTP };
12 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/magic/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { LinkIcon, EnvelopeIcon } from "@heroicons/react/24/outline";
4 | import { tokenParser } from "../lib/utilities";
5 | import { token } from "../lib/slices/tokensSlice";
6 | import { useEffect } from "react";
7 | import { useAppDispatch, useAppSelector } from "../lib/hooks";
8 | import Link from "next/link";
9 | import { RootState } from "../lib/store";
10 | import { useRouter } from "next/navigation";
11 | import { loggedIn, logout } from "../lib/slices/authSlice";
12 |
13 | const redirectRoute = "/login";
14 |
15 | export default function Magic() {
16 | const router = useRouter();
17 | const accessToken = useAppSelector((state: RootState) => token(state));
18 | const isLoggedIn = useAppSelector((state: RootState) => loggedIn(state));
19 | const dispatch = useAppDispatch();
20 |
21 | useEffect(() => {
22 | async function checkCredentials(): Promise {
23 | if (isLoggedIn) {
24 | router.push("/");
25 | }
26 | if (
27 | !(accessToken && tokenParser(accessToken).hasOwnProperty("fingerprint"))
28 | ) {
29 | router.push(redirectRoute);
30 | }
31 | }
32 | checkCredentials();
33 | }, [accessToken, isLoggedIn]); // eslint-disable-line react-hooks/exhaustive-deps
34 |
35 | const removeFingerprint = async () => {
36 | await dispatch(logout());
37 | };
38 |
39 | return (
40 |
41 |
42 |
43 |
44 |
48 |
49 | Check your email
50 |
51 |
52 | We sent you an email with a magic link. Once you click that (or
53 | copy it into this browser) you'll be signed in.
54 |
55 |
56 | Make sure you use the same browser you requested the login from or
57 | it won't work.
58 |
59 |
60 |
61 |
66 |
70 |
71 | If you prefer, use your password & don't email.
72 |
73 |
74 |
75 |
76 |
77 |

82 |
83 |
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/moderation/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Cog8ToothIcon,
5 | UsersIcon,
6 | UserPlusIcon,
7 | } from "@heroicons/react/24/outline";
8 | import { useRouter } from "next/navigation";
9 | import { useState, useEffect } from "react";
10 | import UserTable from "../components/moderation/UserTable";
11 | import CreateUser from "../components/moderation/CreateUser";
12 | import { useAppSelector } from "../lib/hooks";
13 | import { isAdmin } from "../lib/slices/authSlice";
14 |
15 | const navigation = [
16 | { name: "Users", id: "USERS", icon: UsersIcon },
17 | { name: "Create", id: "CREATE", icon: UserPlusIcon },
18 | ];
19 |
20 | export default function Moderation() {
21 | const [selected, changeSelection] = useState("USERS");
22 | const isValidAdmin = useAppSelector((state) => isAdmin(state));
23 |
24 | const router = useRouter();
25 |
26 | const redirectTo = (route: string) => {
27 | router.push(route);
28 | };
29 |
30 | const renderNavigation = () => {
31 | return navigation.map((item) => (
32 |
53 | ));
54 | };
55 |
56 | useEffect(() => {
57 | async function checkAdmin() {
58 | if (!isValidAdmin) redirectTo("/settings");
59 | }
60 | checkAdmin();
61 | }, [isValidAdmin]); // eslint-disable-line react-hooks/exhaustive-deps
62 |
63 | return (
64 |
65 |
66 |
67 |
85 |
86 | {selected === "USERS" && }
87 | {selected === "CREATE" && }
88 |
89 |
90 |
91 |
92 | );
93 | }
94 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/recover-password/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Link from "next/link";
4 | import { useAppDispatch, useAppSelector } from "../lib/hooks";
5 | import { useForm } from "react-hook-form";
6 | import { loggedIn, recoverPassword } from "../lib/slices/authSlice";
7 | import { useRouter } from "next/navigation";
8 | import { useEffect } from "react";
9 | import { RootState } from "../lib/store";
10 |
11 | const redirectRoute = "/";
12 | const schema = {
13 | email: { required: true },
14 | };
15 |
16 | export default function RecoverPassword() {
17 | const dispatch = useAppDispatch();
18 | const isLoggedIn = useAppSelector((state: RootState) => loggedIn(state));
19 |
20 | const router = useRouter();
21 |
22 | const {
23 | register,
24 | handleSubmit,
25 | formState: { errors },
26 | } = useForm();
27 |
28 | async function submit(values: any) {
29 | await dispatch(recoverPassword(values.email));
30 | await new Promise((resolve) => {
31 | setTimeout(() => {
32 | resolve(true);
33 | }, 2000);
34 | });
35 | router.push(redirectRoute);
36 | }
37 |
38 | useEffect(() => {
39 | if (isLoggedIn) router.push(redirectRoute);
40 | });
41 |
42 | return (
43 |
44 |
45 |
46 |
47 |

52 |
53 | Recover your account
54 |
55 |
56 |
108 |
109 |
110 |

115 |
116 |
117 |
118 | );
119 | }
120 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/app/settings/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | KeyIcon,
5 | UserCircleIcon,
6 | UsersIcon,
7 | } from "@heroicons/react/24/outline";
8 | import ValidateEmailButton from "../components/settings/ValidateEmailButton";
9 | import Profile from "../components/settings/Profile";
10 | import Security from "../components/settings/Security";
11 | import { useState, useEffect } from "react";
12 | import { useRouter } from "next/navigation";
13 | import { useAppSelector } from "../lib/hooks";
14 | import { RootState } from "../lib/store";
15 | import { loggedIn, profile } from "../lib/slices/authSlice";
16 |
17 | const navigation = [
18 | { name: "Account", id: "ACCOUNT", icon: UserCircleIcon },
19 | { name: "Security", id: "SECURITY", icon: KeyIcon },
20 | ];
21 |
22 | export default function Settings() {
23 | const [selected, changeSelection] = useState("ACCOUNT");
24 | const currentProfile = useAppSelector((state: RootState) => profile(state));
25 | const isLoggedIn = useAppSelector((state: RootState) => loggedIn(state));
26 |
27 | const router = useRouter();
28 | const redirectTo = (to: string) => router.push(to);
29 |
30 | const renderNavigation = () => {
31 | return navigation.map((item) => (
32 |
53 | ));
54 | };
55 |
56 | useEffect(() => {
57 | async function checkLoggedIn() {
58 | if (!isLoggedIn) redirectTo("/");
59 | }
60 | checkLoggedIn();
61 | }, [isLoggedIn]); // eslint-disable-line react-hooks/exhaustive-deps
62 |
63 | return (
64 |
65 |
66 |
67 |
85 |
86 | {selected === "ACCOUNT" &&
}
87 | {selected === "SECURITY" &&
}
88 |
89 |
90 |
91 |
92 | );
93 | }
94 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | module.exports = {
3 | images: {
4 | remotePatterns: [
5 | {
6 | protocol: "https",
7 | hostname: "**",
8 | },
9 | ],
10 | },
11 | output: "standalone",
12 | };
13 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "full-stack-fastapi-mongodb-react",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "format": "prettier --write ."
11 | },
12 | "dependencies": {
13 | "@headlessui/react": "^1.7.17",
14 | "@heroicons/react": "^2.0.18",
15 | "@mdx-js/loader": "^2.3.0",
16 | "@mdx-js/react": "^2.3.0",
17 | "@next/mdx": "^13.5.3",
18 | "@reduxjs/toolkit": "^1.9.6",
19 | "@tailwindcss/aspect-ratio": "^0.4.2",
20 | "@tailwindcss/forms": "^0.5.6",
21 | "@tailwindcss/postcss": "^4.0.9",
22 | "@tailwindcss/typography": "^0.5.10",
23 | "gray-matter": "^4.0.3",
24 | "next": "^14.0.4",
25 | "prettier": "^3.1.1",
26 | "qrcode.react": "^3.1.0",
27 | "react": "18.2.0",
28 | "react-dom": "18.2.0",
29 | "react-hook-form": "^7.46.2",
30 | "react-redux": "^8.1.2",
31 | "redux": "latest",
32 | "redux-persist": "^6.0.0",
33 | "remark": "^15.0.1",
34 | "remark-gfm": "^4.0.0",
35 | "remark-html": "^16.0.1",
36 | "remark-toc": "^9.0.0",
37 | "webpack": "latest"
38 | },
39 | "devDependencies": {
40 | "@types/mdx": "^2.0.8",
41 | "@types/node": "latest",
42 | "@types/react": "latest",
43 | "@types/react-dom": "latest",
44 | "autoprefixer": "latest",
45 | "eslint": "latest",
46 | "eslint-config-next": "latest",
47 | "eslint-config-prettier": "^9.1.0",
48 | "postcss": "latest",
49 | "tailwindcss": "latest",
50 | "typescript": "latest"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: {
3 | '@tailwindcss/postcss': {},
4 | autoprefixer: {},
5 | },
6 |
7 | };
8 |
9 | export default config;
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 | import colors from "tailwindcss/colors";
3 |
4 | module.exports = {
5 | content: [
6 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
9 | ],
10 | theme: {
11 | extend: {
12 | colors: {
13 | teal: colors.teal,
14 | cyan: colors.cyan,
15 | rose: colors.rose,
16 | },
17 | },
18 | },
19 | corePlugins: {
20 | aspectRatio: false,
21 | },
22 | plugins: [
23 | require("@tailwindcss/typography"),
24 | require("@tailwindcss/forms"),
25 | require("@tailwindcss/aspect-ratio"),
26 | ],
27 | };
28 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@": ["./"],
23 | "@/*": ["./*"]
24 | }
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/scripts/build-push.sh:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env sh
2 |
3 | # Exit in case of error
4 | set -e
5 |
6 | TAG=${TAG?Variable not set} \
7 | FRONTEND_ENV=${FRONTEND_ENV-production} \
8 | sh ./scripts/build.sh
9 |
10 | docker-compose -f docker-compose.yml push
11 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/scripts/build.sh:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env sh
2 |
3 | # Exit in case of error
4 | set -e
5 |
6 | TAG=${TAG?Variable not set} \
7 | FRONTEND_ENV=${FRONTEND_ENV-production} \
8 | docker-compose \
9 | -f docker-compose.yml \
10 | build
11 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/scripts/deploy.sh:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env sh
2 |
3 | # Exit in case of error
4 | set -e
5 |
6 | DOMAIN=${DOMAIN?Variable not set} \
7 | TRAEFIK_TAG=${TRAEFIK_TAG?Variable not set} \
8 | STACK_NAME=${STACK_NAME?Variable not set} \
9 | TAG=${TAG?Variable not set} \
10 | docker-compose \
11 | -f docker-compose.yml \
12 | config > docker-stack.yml
13 |
14 | docker-auto-labels docker-stack.yml
15 |
16 | docker stack deploy -c docker-stack.yml --with-registry-auth "${STACK_NAME?Variable not set}"
17 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/scripts/test-local.sh:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env bash
2 |
3 | # Exit in case of error
4 | set -e
5 |
6 | docker-compose down -v --remove-orphans # Remove possibly previous broken stacks left hanging after an error
7 |
8 | if [ $(uname -s) = "Linux" ]; then
9 | echo "Remove __pycache__ files"
10 | sudo find . -type d -name __pycache__ -exec rm -r {} \+
11 | fi
12 |
13 | docker-compose build
14 | docker-compose up -d
15 | docker-compose exec -T backend bash /app/tests-start.sh "$@"
16 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/scripts/test.sh:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env sh
2 |
3 | # Exit in case of error
4 | set -e
5 |
6 | DOMAIN=backend \
7 | SMTP_HOST="" \
8 | TRAEFIK_PUBLIC_NETWORK_IS_EXTERNAL=false \
9 | TRAEFIK_PUBLIC_NETWORK=traefik-public \
10 | INSTALL_DEV=true \
11 | docker-compose \
12 | -f docker-compose.yml \
13 | config > docker-stack.yml
14 |
15 | docker-compose -f docker-stack.yml build
16 | docker-compose -f docker-stack.yml down -v --remove-orphans # Remove possibly previous broken stacks left hanging after an error
17 | docker-compose -f docker-stack.yml up -d
18 | docker-compose -f docker-stack.yml exec -T backend bash /app/tests-start.sh "$@"
19 | docker-compose -f docker-stack.yml down -v --remove-orphans
20 |
--------------------------------------------------------------------------------