├── .github
├── FUNDING.yml
└── workflows
│ └── issue-manager.yml
├── .gitignore
├── .travis.yml
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── cookiecutter.json
├── hooks
└── post_gen_project.py
├── img
├── dashboard.png
├── docs.png
├── login.png
└── redoc.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}}
├── .env
├── .gitignore
├── .gitlab-ci.yml
├── README.md
├── backend
├── .gitignore
├── app
│ ├── .flake8
│ ├── .gitignore
│ ├── alembic.ini
│ ├── alembic
│ │ ├── README
│ │ ├── env.py
│ │ ├── script.py.mako
│ │ └── versions
│ │ │ ├── .keep
│ │ │ └── d4867f3a4c0a_first_revision.py
│ ├── app
│ │ ├── __init__.py
│ │ ├── api
│ │ │ ├── __init__.py
│ │ │ ├── api_v1
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api.py
│ │ │ │ └── endpoints
│ │ │ │ │ ├── __init__.py
│ │ │ │ │ ├── items.py
│ │ │ │ │ ├── login.py
│ │ │ │ │ ├── users.py
│ │ │ │ │ └── utils.py
│ │ │ └── deps.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_item.py
│ │ │ └── crud_user.py
│ │ ├── db
│ │ │ ├── __init__.py
│ │ │ ├── base.py
│ │ │ ├── base_class.py
│ │ │ ├── init_db.py
│ │ │ └── session.py
│ │ ├── email-templates
│ │ │ ├── build
│ │ │ │ ├── new_account.html
│ │ │ │ ├── reset_password.html
│ │ │ │ └── test_email.html
│ │ │ └── src
│ │ │ │ ├── new_account.mjml
│ │ │ │ ├── reset_password.mjml
│ │ │ │ └── test_email.mjml
│ │ ├── initial_data.py
│ │ ├── main.py
│ │ ├── models
│ │ │ ├── __init__.py
│ │ │ ├── item.py
│ │ │ └── user.py
│ │ ├── schemas
│ │ │ ├── __init__.py
│ │ │ ├── item.py
│ │ │ ├── msg.py
│ │ │ ├── token.py
│ │ │ └── user.py
│ │ ├── tests
│ │ │ ├── .gitignore
│ │ │ ├── __init__.py
│ │ │ ├── api
│ │ │ │ ├── __init__.py
│ │ │ │ └── api_v1
│ │ │ │ │ ├── __init__.py
│ │ │ │ │ ├── test_celery.py
│ │ │ │ │ ├── test_items.py
│ │ │ │ │ ├── test_login.py
│ │ │ │ │ └── test_users.py
│ │ │ ├── conftest.py
│ │ │ ├── crud
│ │ │ │ ├── __init__.py
│ │ │ │ ├── test_item.py
│ │ │ │ └── test_user.py
│ │ │ └── utils
│ │ │ │ ├── __init__.py
│ │ │ │ ├── item.py
│ │ │ │ ├── user.py
│ │ │ │ └── utils.py
│ │ ├── tests_pre_start.py
│ │ ├── utils.py
│ │ └── worker.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
├── .dockerignore
├── .env
├── .gitignore
├── Dockerfile
├── README.md
├── babel.config.js
├── nginx-backend-not-found.conf
├── package.json
├── public
│ ├── favicon.ico
│ ├── img
│ │ └── icons
│ │ │ ├── android-chrome-192x192.png
│ │ │ ├── android-chrome-512x512.png
│ │ │ ├── apple-touch-icon-120x120.png
│ │ │ ├── apple-touch-icon-152x152.png
│ │ │ ├── apple-touch-icon-180x180.png
│ │ │ ├── apple-touch-icon-60x60.png
│ │ │ ├── apple-touch-icon-76x76.png
│ │ │ ├── apple-touch-icon.png
│ │ │ ├── favicon-16x16.png
│ │ │ ├── favicon-32x32.png
│ │ │ ├── msapplication-icon-144x144.png
│ │ │ ├── mstile-150x150.png
│ │ │ └── safari-pinned-tab.svg
│ ├── index.html
│ ├── manifest.json
│ └── robots.txt
├── src
│ ├── App.vue
│ ├── api.ts
│ ├── assets
│ │ └── logo.png
│ ├── component-hooks.ts
│ ├── components
│ │ ├── NotificationsManager.vue
│ │ ├── RouterComponent.vue
│ │ └── UploadButton.vue
│ ├── env.ts
│ ├── interfaces
│ │ └── index.ts
│ ├── main.ts
│ ├── plugins
│ │ ├── vee-validate.ts
│ │ └── vuetify.ts
│ ├── registerServiceWorker.ts
│ ├── router.ts
│ ├── shims-tsx.d.ts
│ ├── shims-vue.d.ts
│ ├── store
│ │ ├── admin
│ │ │ ├── actions.ts
│ │ │ ├── getters.ts
│ │ │ ├── index.ts
│ │ │ ├── mutations.ts
│ │ │ └── state.ts
│ │ ├── index.ts
│ │ ├── main
│ │ │ ├── actions.ts
│ │ │ ├── getters.ts
│ │ │ ├── index.ts
│ │ │ ├── mutations.ts
│ │ │ └── state.ts
│ │ └── state.ts
│ ├── utils.ts
│ └── views
│ │ ├── Login.vue
│ │ ├── PasswordRecovery.vue
│ │ ├── ResetPassword.vue
│ │ └── main
│ │ ├── Dashboard.vue
│ │ ├── Main.vue
│ │ ├── Start.vue
│ │ ├── admin
│ │ ├── Admin.vue
│ │ ├── AdminUsers.vue
│ │ ├── CreateUser.vue
│ │ └── EditUser.vue
│ │ └── profile
│ │ ├── UserProfile.vue
│ │ ├── UserProfileEdit.vue
│ │ └── UserProfileEditPassword.vue
├── tests
│ └── unit
│ │ └── upload-button.spec.ts
├── tsconfig.json
├── tslint.json
└── vue.config.js
└── scripts
├── build-push.sh
├── build.sh
├── deploy.sh
├── test-local.sh
└── test.sh
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [tiangolo]
2 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 PostgreSQL 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-postgresql/`, call it from `~/code/`, like:
12 |
13 | ```console
14 | $ cd ~/code/
15 |
16 | $ bash ./full-stack-fastapi-postgresql/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-postgresql/`, call it from `~/code/`, like:
38 |
39 | ```console
40 | $ cd ~/code/
41 |
42 | $ bash ./full-stack-fastapi-postgresql/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 `.gitlab-ci.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-postgresql/
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-postgresql/
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) 2019 Sebastián Ramírez
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 |
--------------------------------------------------------------------------------
/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 |
7 | "docker_swarm_stack_name_main": "{{cookiecutter.domain_main|replace('.', '-')}}",
8 | "docker_swarm_stack_name_staging": "{{cookiecutter.domain_staging|replace('.', '-')}}",
9 |
10 | "secret_key": "changethis",
11 | "first_superuser": "admin@{{cookiecutter.domain_main}}",
12 | "first_superuser_password": "changethis",
13 | "backend_cors_origins": "[\"http://localhost\", \"http://localhost:4200\", \"http://localhost:3000\", \"http://localhost:8080\", \"https://localhost\", \"https://localhost:4200\", \"https://localhost:3000\", \"https://localhost:8080\", \"http://dev.{{cookiecutter.domain_main}}\", \"https://{{cookiecutter.domain_staging}}\", \"https://{{cookiecutter.domain_main}}\", \"http://local.dockertoolbox.tiangolo.com\", \"http://localhost.tiangolo.com\"]",
14 | "smtp_port": "587",
15 | "smtp_host": "",
16 | "smtp_user": "",
17 | "smtp_password": "",
18 | "smtp_emails_from_email": "info@{{cookiecutter.domain_main}}",
19 |
20 | "postgres_password": "changethis",
21 | "pgadmin_default_user": "{{cookiecutter.first_superuser}}",
22 | "pgadmin_default_user_password": "{{cookiecutter.first_superuser_password}}",
23 |
24 | "traefik_constraint_tag": "{{cookiecutter.domain_main}}",
25 | "traefik_constraint_tag_staging": "{{cookiecutter.domain_staging}}",
26 | "traefik_public_constraint_tag": "traefik-public",
27 |
28 | "flower_auth": "admin:{{cookiecutter.first_superuser_password}}",
29 |
30 | "sentry_dsn": "",
31 |
32 | "docker_image_prefix": "",
33 |
34 | "docker_image_backend": "{{cookiecutter.docker_image_prefix}}backend",
35 | "docker_image_celeryworker": "{{cookiecutter.docker_image_prefix}}celeryworker",
36 | "docker_image_frontend": "{{cookiecutter.docker_image_prefix}}frontend",
37 |
38 | "_copy_without_render": [
39 | "frontend/src/**/*.html",
40 | "frontend/src/**/*.vue",
41 | "frontend/node_modules/*",
42 | "backend/app/app/email-templates/**"
43 | ]
44 | }
45 |
--------------------------------------------------------------------------------
/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/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/img/dashboard.png
--------------------------------------------------------------------------------
/img/docs.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/img/docs.png
--------------------------------------------------------------------------------
/img/login.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/img/login.png
--------------------------------------------------------------------------------
/img/redoc.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/img/redoc.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-postgresql ] ; 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-postgresql/\{\{cookiecutter.project_slug\}\}/*
19 |
20 | rsync -a --exclude=node_modules ./dev-fsfp/* ./full-stack-fastapi-postgresql/\{\{cookiecutter.project_slug\}\}/
21 |
22 | rsync -a ./dev-fsfp/{.env,.gitignore,.gitlab-ci.yml} ./full-stack-fastapi-postgresql/\{\{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-postgresql ] ; 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-postgresql 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/dist
9 | git checkout \{\{cookiecutter.project_slug\}\}/README.md
10 | git checkout \{\{cookiecutter.project_slug\}\}/.gitlab-ci.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}}/.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_CORS_ORIGINS={{cookiecutter.backend_cors_origins}}
18 | PROJECT_NAME={{cookiecutter.project_name}}
19 | SECRET_KEY={{cookiecutter.secret_key}}
20 | FIRST_SUPERUSER={{cookiecutter.first_superuser}}
21 | FIRST_SUPERUSER_PASSWORD={{cookiecutter.first_superuser_password}}
22 | SMTP_TLS=True
23 | SMTP_PORT={{cookiecutter.smtp_port}}
24 | SMTP_HOST={{cookiecutter.smtp_host}}
25 | SMTP_USER={{cookiecutter.smtp_user}}
26 | SMTP_PASSWORD={{cookiecutter.smtp_password}}
27 | EMAILS_FROM_EMAIL={{cookiecutter.smtp_emails_from_email}}
28 |
29 | USERS_OPEN_REGISTRATION=False
30 |
31 | SENTRY_DSN={{cookiecutter.sentry_dsn}}
32 |
33 | # Flower
34 | FLOWER_BASIC_AUTH={{cookiecutter.flower_auth}}
35 |
36 | # Postgres
37 | POSTGRES_SERVER=db
38 | POSTGRES_USER=postgres
39 | POSTGRES_PASSWORD={{cookiecutter.postgres_password}}
40 | POSTGRES_DB=app
41 |
42 | # PgAdmin
43 | PGADMIN_LISTEN_PORT=5050
44 | PGADMIN_DEFAULT_EMAIL={{cookiecutter.pgadmin_default_user}}
45 | PGADMIN_DEFAULT_PASSWORD={{cookiecutter.pgadmin_default_user_password}}
46 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/.gitignore:
--------------------------------------------------------------------------------
1 | .vscode
2 | .mypy_cache
3 | docker-stack.yml
4 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/.gitlab-ci.yml:
--------------------------------------------------------------------------------
1 | image: tiangolo/docker-with-compose
2 |
3 | before_script:
4 | - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
5 | - pip install docker-auto-labels
6 |
7 | stages:
8 | - test
9 | - build
10 | - deploy
11 |
12 | tests:
13 | stage: test
14 | script:
15 | - sh ./scripts/test.sh
16 | tags:
17 | - build
18 | - test
19 |
20 | build-stag:
21 | stage: build
22 | script:
23 | - TAG=stag FRONTEND_ENV=staging sh ./scripts/build-push.sh
24 | only:
25 | - master
26 | tags:
27 | - build
28 | - test
29 |
30 | build-prod:
31 | stage: build
32 | script:
33 | - TAG=prod FRONTEND_ENV=production sh ./scripts/build-push.sh
34 | only:
35 | - production
36 | tags:
37 | - build
38 | - test
39 |
40 | deploy-stag:
41 | stage: deploy
42 | script:
43 | - >
44 | DOMAIN={{cookiecutter.domain_staging}}
45 | TRAEFIK_TAG={{cookiecutter.traefik_constraint_tag_staging}}
46 | STACK_NAME={{cookiecutter.docker_swarm_stack_name_staging}}
47 | TAG=stag
48 | sh ./scripts/deploy.sh
49 | environment:
50 | name: staging
51 | url: https://{{cookiecutter.domain_staging}}
52 | only:
53 | - master
54 | tags:
55 | - swarm
56 | - stag
57 |
58 | deploy-prod:
59 | stage: deploy
60 | script:
61 | - >
62 | DOMAIN={{cookiecutter.domain_main}}
63 | TRAEFIK_TAG={{cookiecutter.traefik_constraint_tag}}
64 | STACK_NAME={{cookiecutter.docker_swarm_stack_name_main}}
65 | TAG=prod
66 | sh ./scripts/deploy.sh
67 | environment:
68 | name: production
69 | url: https://{{cookiecutter.domain_main}}
70 | only:
71 | - production
72 | tags:
73 | - swarm
74 | - prod
75 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__
2 | app.egg-info
3 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | max-line-length = 88
3 | exclude = .git,__pycache__,__init__.py,.mypy_cache,.pytest_cache
4 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/.gitignore:
--------------------------------------------------------------------------------
1 | .mypy_cache
2 | .coverage
3 | htmlcov
4 |
--------------------------------------------------------------------------------
/{{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/alembic/README:
--------------------------------------------------------------------------------
1 | Generic single-database configuration.
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/alembic/env.py:
--------------------------------------------------------------------------------
1 | from __future__ import with_statement
2 |
3 | import os
4 |
5 | from alembic import context
6 | from sqlalchemy import engine_from_config, pool
7 | from logging.config import fileConfig
8 |
9 | # this is the Alembic Config object, which provides
10 | # access to the values within the .ini file in use.
11 | config = context.config
12 |
13 | # Interpret the config file for Python logging.
14 | # This line sets up loggers basically.
15 | fileConfig(config.config_file_name)
16 |
17 | # add your model's MetaData object here
18 | # for 'autogenerate' support
19 | # from myapp import mymodel
20 | # target_metadata = mymodel.Base.metadata
21 | # target_metadata = None
22 |
23 | from app.db.base import Base # noqa
24 |
25 | target_metadata = Base.metadata
26 |
27 | # other values from the config, defined by the needs of env.py,
28 | # can be acquired:
29 | # my_important_option = config.get_main_option("my_important_option")
30 | # ... etc.
31 |
32 |
33 | def get_url():
34 | user = os.getenv("POSTGRES_USER", "postgres")
35 | password = os.getenv("POSTGRES_PASSWORD", "")
36 | server = os.getenv("POSTGRES_SERVER", "db")
37 | db = os.getenv("POSTGRES_DB", "app")
38 | return f"postgresql://{user}:{password}@{server}/{db}"
39 |
40 |
41 | def run_migrations_offline():
42 | """Run migrations in 'offline' mode.
43 |
44 | This configures the context with just a URL
45 | and not an Engine, though an Engine is acceptable
46 | here as well. By skipping the Engine creation
47 | we don't even need a DBAPI to be available.
48 |
49 | Calls to context.execute() here emit the given string to the
50 | script output.
51 |
52 | """
53 | url = get_url()
54 | context.configure(
55 | url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True
56 | )
57 |
58 | with context.begin_transaction():
59 | context.run_migrations()
60 |
61 |
62 | def run_migrations_online():
63 | """Run migrations in 'online' mode.
64 |
65 | In this scenario we need to create an Engine
66 | and associate a connection with the context.
67 |
68 | """
69 | configuration = config.get_section(config.config_ini_section)
70 | configuration["sqlalchemy.url"] = get_url()
71 | connectable = engine_from_config(
72 | configuration, prefix="sqlalchemy.", poolclass=pool.NullPool,
73 | )
74 |
75 | with connectable.connect() as connection:
76 | context.configure(
77 | connection=connection, target_metadata=target_metadata, compare_type=True
78 | )
79 |
80 | with context.begin_transaction():
81 | context.run_migrations()
82 |
83 |
84 | if context.is_offline_mode():
85 | run_migrations_offline()
86 | else:
87 | run_migrations_online()
88 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/alembic/script.py.mako:
--------------------------------------------------------------------------------
1 | """${message}
2 |
3 | Revision ID: ${up_revision}
4 | Revises: ${down_revision | comma,n}
5 | Create Date: ${create_date}
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 | ${imports if imports else ""}
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = ${repr(up_revision)}
14 | down_revision = ${repr(down_revision)}
15 | branch_labels = ${repr(branch_labels)}
16 | depends_on = ${repr(depends_on)}
17 |
18 |
19 | def upgrade():
20 | ${upgrades if upgrades else "pass"}
21 |
22 |
23 | def downgrade():
24 | ${downgrades if downgrades else "pass"}
25 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/alembic/versions/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{cookiecutter.project_slug}}/backend/app/alembic/versions/.keep
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/alembic/versions/d4867f3a4c0a_first_revision.py:
--------------------------------------------------------------------------------
1 | """First revision
2 |
3 | Revision ID: d4867f3a4c0a
4 | Revises:
5 | Create Date: 2019-04-17 13:53:32.978401
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = "d4867f3a4c0a"
14 | down_revision = None
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade():
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.create_table(
22 | "user",
23 | sa.Column("id", sa.Integer(), nullable=False),
24 | sa.Column("full_name", sa.String(), nullable=True),
25 | sa.Column("email", sa.String(), nullable=True),
26 | sa.Column("hashed_password", sa.String(), nullable=True),
27 | sa.Column("is_active", sa.Boolean(), nullable=True),
28 | sa.Column("is_superuser", sa.Boolean(), nullable=True),
29 | sa.PrimaryKeyConstraint("id"),
30 | )
31 | op.create_index(op.f("ix_user_email"), "user", ["email"], unique=True)
32 | op.create_index(op.f("ix_user_full_name"), "user", ["full_name"], unique=False)
33 | op.create_index(op.f("ix_user_id"), "user", ["id"], unique=False)
34 | op.create_table(
35 | "item",
36 | sa.Column("id", sa.Integer(), nullable=False),
37 | sa.Column("title", sa.String(), nullable=True),
38 | sa.Column("description", sa.String(), nullable=True),
39 | sa.Column("owner_id", sa.Integer(), nullable=True),
40 | sa.ForeignKeyConstraint(["owner_id"], ["user.id"],),
41 | sa.PrimaryKeyConstraint("id"),
42 | )
43 | op.create_index(op.f("ix_item_description"), "item", ["description"], unique=False)
44 | op.create_index(op.f("ix_item_id"), "item", ["id"], unique=False)
45 | op.create_index(op.f("ix_item_title"), "item", ["title"], unique=False)
46 | # ### end Alembic commands ###
47 |
48 |
49 | def downgrade():
50 | # ### commands auto generated by Alembic - please adjust! ###
51 | op.drop_index(op.f("ix_item_title"), table_name="item")
52 | op.drop_index(op.f("ix_item_id"), table_name="item")
53 | op.drop_index(op.f("ix_item_description"), table_name="item")
54 | op.drop_table("item")
55 | op.drop_index(op.f("ix_user_id"), table_name="user")
56 | op.drop_index(op.f("ix_user_full_name"), table_name="user")
57 | op.drop_index(op.f("ix_user_email"), table_name="user")
58 | op.drop_table("user")
59 | # ### end Alembic commands ###
60 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{cookiecutter.project_slug}}/backend/app/app/__init__.py
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/api/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{cookiecutter.project_slug}}/backend/app/app/api/__init__.py
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{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 items, login, users, utils
4 |
5 | api_router = APIRouter()
6 | api_router.include_router(login.router, tags=["login"])
7 | api_router.include_router(users.router, prefix="/users", tags=["users"])
8 | api_router.include_router(utils.router, prefix="/utils", tags=["utils"])
9 | api_router.include_router(items.router, prefix="/items", tags=["items"])
10 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/__init__.py
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/items.py:
--------------------------------------------------------------------------------
1 | from typing import Any, List
2 |
3 | from fastapi import APIRouter, Depends, HTTPException
4 | from sqlalchemy.orm import Session
5 |
6 | from app import crud, models, schemas
7 | from app.api import deps
8 |
9 | router = APIRouter()
10 |
11 |
12 | @router.get("/", response_model=List[schemas.Item])
13 | def read_items(
14 | db: Session = Depends(deps.get_db),
15 | skip: int = 0,
16 | limit: int = 100,
17 | current_user: models.User = Depends(deps.get_current_active_user),
18 | ) -> Any:
19 | """
20 | Retrieve items.
21 | """
22 | if crud.user.is_superuser(current_user):
23 | items = crud.item.get_multi(db, skip=skip, limit=limit)
24 | else:
25 | items = crud.item.get_multi_by_owner(
26 | db=db, owner_id=current_user.id, skip=skip, limit=limit
27 | )
28 | return items
29 |
30 |
31 | @router.post("/", response_model=schemas.Item)
32 | def create_item(
33 | *,
34 | db: Session = Depends(deps.get_db),
35 | item_in: schemas.ItemCreate,
36 | current_user: models.User = Depends(deps.get_current_active_user),
37 | ) -> Any:
38 | """
39 | Create new item.
40 | """
41 | item = crud.item.create_with_owner(db=db, obj_in=item_in, owner_id=current_user.id)
42 | return item
43 |
44 |
45 | @router.put("/{id}", response_model=schemas.Item)
46 | def update_item(
47 | *,
48 | db: Session = Depends(deps.get_db),
49 | id: int,
50 | item_in: schemas.ItemUpdate,
51 | current_user: models.User = Depends(deps.get_current_active_user),
52 | ) -> Any:
53 | """
54 | Update an item.
55 | """
56 | item = crud.item.get(db=db, id=id)
57 | if not item:
58 | raise HTTPException(status_code=404, detail="Item not found")
59 | if not crud.user.is_superuser(current_user) and (item.owner_id != current_user.id):
60 | raise HTTPException(status_code=400, detail="Not enough permissions")
61 | item = crud.item.update(db=db, db_obj=item, obj_in=item_in)
62 | return item
63 |
64 |
65 | @router.get("/{id}", response_model=schemas.Item)
66 | def read_item(
67 | *,
68 | db: Session = Depends(deps.get_db),
69 | id: int,
70 | current_user: models.User = Depends(deps.get_current_active_user),
71 | ) -> Any:
72 | """
73 | Get item by ID.
74 | """
75 | item = crud.item.get(db=db, id=id)
76 | if not item:
77 | raise HTTPException(status_code=404, detail="Item not found")
78 | if not crud.user.is_superuser(current_user) and (item.owner_id != current_user.id):
79 | raise HTTPException(status_code=400, detail="Not enough permissions")
80 | return item
81 |
82 |
83 | @router.delete("/{id}", response_model=schemas.Item)
84 | def delete_item(
85 | *,
86 | db: Session = Depends(deps.get_db),
87 | id: int,
88 | current_user: models.User = Depends(deps.get_current_active_user),
89 | ) -> Any:
90 | """
91 | Delete an item.
92 | """
93 | item = crud.item.get(db=db, id=id)
94 | if not item:
95 | raise HTTPException(status_code=404, detail="Item not found")
96 | if not crud.user.is_superuser(current_user) and (item.owner_id != current_user.id):
97 | raise HTTPException(status_code=400, detail="Not enough permissions")
98 | item = crud.item.remove(db=db, id=id)
99 | return item
100 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/login.py:
--------------------------------------------------------------------------------
1 | from datetime import timedelta
2 | from typing import Any
3 |
4 | from fastapi import APIRouter, Body, Depends, HTTPException
5 | from fastapi.security import OAuth2PasswordRequestForm
6 | from sqlalchemy.orm import Session
7 |
8 | from app import crud, models, schemas
9 | from app.api import deps
10 | from app.core import security
11 | from app.core.config import settings
12 | from app.core.security import get_password_hash
13 | from app.utils import (
14 | generate_password_reset_token,
15 | send_reset_password_email,
16 | verify_password_reset_token,
17 | )
18 |
19 | router = APIRouter()
20 |
21 |
22 | @router.post("/login/access-token", response_model=schemas.Token)
23 | def login_access_token(
24 | db: Session = Depends(deps.get_db), form_data: OAuth2PasswordRequestForm = Depends()
25 | ) -> Any:
26 | """
27 | OAuth2 compatible token login, get an access token for future requests
28 | """
29 | user = crud.user.authenticate(
30 | db, email=form_data.username, password=form_data.password
31 | )
32 | if not user:
33 | raise HTTPException(status_code=400, detail="Incorrect email or password")
34 | elif not crud.user.is_active(user):
35 | raise HTTPException(status_code=400, detail="Inactive user")
36 | access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
37 | return {
38 | "access_token": security.create_access_token(
39 | user.id, expires_delta=access_token_expires
40 | ),
41 | "token_type": "bearer",
42 | }
43 |
44 |
45 | @router.post("/login/test-token", response_model=schemas.User)
46 | def test_token(current_user: models.User = Depends(deps.get_current_user)) -> Any:
47 | """
48 | Test access token
49 | """
50 | return current_user
51 |
52 |
53 | @router.post("/password-recovery/{email}", response_model=schemas.Msg)
54 | def recover_password(email: str, db: Session = Depends(deps.get_db)) -> Any:
55 | """
56 | Password Recovery
57 | """
58 | user = crud.user.get_by_email(db, email=email)
59 |
60 | if not user:
61 | raise HTTPException(
62 | status_code=404,
63 | detail="The user with this username does not exist in the system.",
64 | )
65 | password_reset_token = generate_password_reset_token(email=email)
66 | send_reset_password_email(
67 | email_to=user.email, email=email, token=password_reset_token
68 | )
69 | return {"msg": "Password recovery email sent"}
70 |
71 |
72 | @router.post("/reset-password/", response_model=schemas.Msg)
73 | def reset_password(
74 | token: str = Body(...),
75 | new_password: str = Body(...),
76 | db: Session = Depends(deps.get_db),
77 | ) -> Any:
78 | """
79 | Reset password
80 | """
81 | email = verify_password_reset_token(token)
82 | if not email:
83 | raise HTTPException(status_code=400, detail="Invalid token")
84 | user = crud.user.get_by_email(db, email=email)
85 | if not user:
86 | raise HTTPException(
87 | status_code=404,
88 | detail="The user with this username does not exist in the system.",
89 | )
90 | elif not crud.user.is_active(user):
91 | raise HTTPException(status_code=400, detail="Inactive user")
92 | hashed_password = get_password_hash(new_password)
93 | user.hashed_password = hashed_password
94 | db.add(user)
95 | db.commit()
96 | return {"msg": "Password updated successfully"}
97 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/users.py:
--------------------------------------------------------------------------------
1 | from typing import Any, List
2 |
3 | from fastapi import APIRouter, Body, Depends, HTTPException
4 | from fastapi.encoders import jsonable_encoder
5 | from pydantic.networks import EmailStr
6 | from sqlalchemy.orm import Session
7 |
8 | from app import crud, models, schemas
9 | from app.api import deps
10 | from app.core.config import settings
11 | from app.utils import send_new_account_email
12 |
13 | router = APIRouter()
14 |
15 |
16 | @router.get("/", response_model=List[schemas.User])
17 | def read_users(
18 | db: Session = Depends(deps.get_db),
19 | skip: int = 0,
20 | limit: int = 100,
21 | current_user: models.User = Depends(deps.get_current_active_superuser),
22 | ) -> Any:
23 | """
24 | Retrieve users.
25 | """
26 | users = crud.user.get_multi(db, skip=skip, limit=limit)
27 | return users
28 |
29 |
30 | @router.post("/", response_model=schemas.User)
31 | def create_user(
32 | *,
33 | db: Session = Depends(deps.get_db),
34 | user_in: schemas.UserCreate,
35 | current_user: models.User = Depends(deps.get_current_active_superuser),
36 | ) -> Any:
37 | """
38 | Create new user.
39 | """
40 | user = crud.user.get_by_email(db, email=user_in.email)
41 | if user:
42 | raise HTTPException(
43 | status_code=400,
44 | detail="The user with this username already exists in the system.",
45 | )
46 | user = crud.user.create(db, obj_in=user_in)
47 | if settings.EMAILS_ENABLED and user_in.email:
48 | send_new_account_email(
49 | email_to=user_in.email, username=user_in.email, password=user_in.password
50 | )
51 | return user
52 |
53 |
54 | @router.put("/me", response_model=schemas.User)
55 | def update_user_me(
56 | *,
57 | db: Session = Depends(deps.get_db),
58 | password: str = Body(None),
59 | full_name: str = Body(None),
60 | email: EmailStr = Body(None),
61 | current_user: models.User = Depends(deps.get_current_active_user),
62 | ) -> Any:
63 | """
64 | Update own user.
65 | """
66 | current_user_data = jsonable_encoder(current_user)
67 | user_in = schemas.UserUpdate(**current_user_data)
68 | if password is not None:
69 | user_in.password = password
70 | if full_name is not None:
71 | user_in.full_name = full_name
72 | if email is not None:
73 | user_in.email = email
74 | user = crud.user.update(db, db_obj=current_user, obj_in=user_in)
75 | return user
76 |
77 |
78 | @router.get("/me", response_model=schemas.User)
79 | def read_user_me(
80 | db: Session = Depends(deps.get_db),
81 | current_user: models.User = Depends(deps.get_current_active_user),
82 | ) -> Any:
83 | """
84 | Get current user.
85 | """
86 | return current_user
87 |
88 |
89 | @router.post("/open", response_model=schemas.User)
90 | def create_user_open(
91 | *,
92 | db: Session = Depends(deps.get_db),
93 | password: str = Body(...),
94 | email: EmailStr = Body(...),
95 | full_name: str = Body(None),
96 | ) -> Any:
97 | """
98 | Create new user without the need to be logged in.
99 | """
100 | if not settings.USERS_OPEN_REGISTRATION:
101 | raise HTTPException(
102 | status_code=403,
103 | detail="Open user registration is forbidden on this server",
104 | )
105 | user = crud.user.get_by_email(db, email=email)
106 | if user:
107 | raise HTTPException(
108 | status_code=400,
109 | detail="The user with this username already exists in the system",
110 | )
111 | user_in = schemas.UserCreate(password=password, email=email, full_name=full_name)
112 | user = crud.user.create(db, obj_in=user_in)
113 | return user
114 |
115 |
116 | @router.get("/{user_id}", response_model=schemas.User)
117 | def read_user_by_id(
118 | user_id: int,
119 | current_user: models.User = Depends(deps.get_current_active_user),
120 | db: Session = Depends(deps.get_db),
121 | ) -> Any:
122 | """
123 | Get a specific user by id.
124 | """
125 | user = crud.user.get(db, id=user_id)
126 | if user == current_user:
127 | return user
128 | if not crud.user.is_superuser(current_user):
129 | raise HTTPException(
130 | status_code=400, detail="The user doesn't have enough privileges"
131 | )
132 | return user
133 |
134 |
135 | @router.put("/{user_id}", response_model=schemas.User)
136 | def update_user(
137 | *,
138 | db: Session = Depends(deps.get_db),
139 | user_id: int,
140 | user_in: schemas.UserUpdate,
141 | current_user: models.User = Depends(deps.get_current_active_superuser),
142 | ) -> Any:
143 | """
144 | Update a user.
145 | """
146 | user = crud.user.get(db, id=user_id)
147 | if not user:
148 | raise HTTPException(
149 | status_code=404,
150 | detail="The user with this username does not exist in the system",
151 | )
152 | user = crud.user.update(db, db_obj=user, obj_in=user_in)
153 | return user
154 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/utils.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from fastapi import APIRouter, Depends
4 | from pydantic.networks import EmailStr
5 |
6 | from app import models, schemas
7 | from app.api import deps
8 | from app.core.celery_app import celery_app
9 | from app.utils import send_test_email
10 |
11 | router = APIRouter()
12 |
13 |
14 | @router.post("/test-celery/", response_model=schemas.Msg, status_code=201)
15 | def test_celery(
16 | msg: schemas.Msg,
17 | current_user: models.User = Depends(deps.get_current_active_superuser),
18 | ) -> Any:
19 | """
20 | Test Celery worker.
21 | """
22 | celery_app.send_task("app.worker.test_celery", args=[msg.msg])
23 | return {"msg": "Word received"}
24 |
25 |
26 | @router.post("/test-email/", response_model=schemas.Msg, status_code=201)
27 | def test_email(
28 | email_to: EmailStr,
29 | current_user: models.User = Depends(deps.get_current_active_superuser),
30 | ) -> Any:
31 | """
32 | Test emails.
33 | """
34 | send_test_email(email_to=email_to)
35 | return {"msg": "Test email sent"}
36 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/api/deps.py:
--------------------------------------------------------------------------------
1 | from typing import Generator
2 |
3 | from fastapi import Depends, HTTPException, status
4 | from fastapi.security import OAuth2PasswordBearer
5 | from jose import jwt
6 | from pydantic import ValidationError
7 | from sqlalchemy.orm import Session
8 |
9 | from app import crud, models, schemas
10 | from app.core import security
11 | from app.core.config import settings
12 | from app.db.session import SessionLocal
13 |
14 | reusable_oauth2 = OAuth2PasswordBearer(
15 | tokenUrl=f"{settings.API_V1_STR}/login/access-token"
16 | )
17 |
18 |
19 | def get_db() -> Generator:
20 | try:
21 | db = SessionLocal()
22 | yield db
23 | finally:
24 | db.close()
25 |
26 |
27 | def get_current_user(
28 | db: Session = Depends(get_db), token: str = Depends(reusable_oauth2)
29 | ) -> models.User:
30 | try:
31 | payload = jwt.decode(
32 | token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
33 | )
34 | token_data = schemas.TokenPayload(**payload)
35 | except (jwt.JWTError, ValidationError):
36 | raise HTTPException(
37 | status_code=status.HTTP_403_FORBIDDEN,
38 | detail="Could not validate credentials",
39 | )
40 | user = crud.user.get(db, id=token_data.sub)
41 | if not user:
42 | raise HTTPException(status_code=404, detail="User not found")
43 | return user
44 |
45 |
46 | def get_current_active_user(
47 | current_user: models.User = Depends(get_current_user),
48 | ) -> models.User:
49 | if not crud.user.is_active(current_user):
50 | raise HTTPException(status_code=400, detail="Inactive user")
51 | return current_user
52 |
53 |
54 | def get_current_active_superuser(
55 | current_user: models.User = Depends(get_current_user),
56 | ) -> models.User:
57 | if not crud.user.is_superuser(current_user):
58 | raise HTTPException(
59 | status_code=400, detail="The user doesn't have enough privileges"
60 | )
61 | return current_user
62 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/backend_pre_start.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed
4 |
5 | from app.db.session import SessionLocal
6 |
7 | logging.basicConfig(level=logging.INFO)
8 | logger = logging.getLogger(__name__)
9 |
10 | max_tries = 60 * 5 # 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 | def init() -> None:
21 | try:
22 | db = SessionLocal()
23 | # Try to create session to check if DB is awake
24 | db.execute("SELECT 1")
25 | except Exception as e:
26 | logger.error(e)
27 | raise e
28 |
29 |
30 | def main() -> None:
31 | logger.info("Initializing service")
32 | init()
33 | logger.info("Service finished initializing")
34 |
35 |
36 | if __name__ == "__main__":
37 | main()
38 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/celeryworker_pre_start.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed
4 |
5 | from app.db.session import SessionLocal
6 |
7 | logging.basicConfig(level=logging.INFO)
8 | logger = logging.getLogger(__name__)
9 |
10 | max_tries = 60 * 5 # 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 | def init() -> None:
21 | try:
22 | # Try to create session to check if DB is awake
23 | db = SessionLocal()
24 | db.execute("SELECT 1")
25 | except Exception as e:
26 | logger.error(e)
27 | raise e
28 |
29 |
30 | def main() -> None:
31 | logger.info("Initializing service")
32 | init()
33 | logger.info("Service finished initializing")
34 |
35 |
36 | if __name__ == "__main__":
37 | main()
38 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/core/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{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.test_celery": "main-queue"}
6 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/core/config.py:
--------------------------------------------------------------------------------
1 | import secrets
2 | from typing import Any, Dict, List, Optional, Union
3 |
4 | from pydantic import AnyHttpUrl, BaseSettings, EmailStr, HttpUrl, PostgresDsn, validator
5 |
6 |
7 | class Settings(BaseSettings):
8 | API_V1_STR: str = "/api/v1"
9 | SECRET_KEY: str = secrets.token_urlsafe(32)
10 | # 60 minutes * 24 hours * 8 days = 8 days
11 | ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
12 | SERVER_NAME: str
13 | SERVER_HOST: AnyHttpUrl
14 | # BACKEND_CORS_ORIGINS is a JSON-formatted list of origins
15 | # e.g: '["http://localhost", "http://localhost:4200", "http://localhost:3000", \
16 | # "http://localhost:8080", "http://local.dockertoolbox.tiangolo.com"]'
17 | BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
18 |
19 | @validator("BACKEND_CORS_ORIGINS", pre=True)
20 | def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]:
21 | if isinstance(v, str) and not v.startswith("["):
22 | return [i.strip() for i in v.split(",")]
23 | elif isinstance(v, (list, str)):
24 | return v
25 | raise ValueError(v)
26 |
27 | PROJECT_NAME: str
28 | SENTRY_DSN: Optional[HttpUrl] = None
29 |
30 | @validator("SENTRY_DSN", pre=True)
31 | def sentry_dsn_can_be_blank(cls, v: str) -> Optional[str]:
32 | if len(v) == 0:
33 | return None
34 | return v
35 |
36 | POSTGRES_SERVER: str
37 | POSTGRES_USER: str
38 | POSTGRES_PASSWORD: str
39 | POSTGRES_DB: str
40 | SQLALCHEMY_DATABASE_URI: Optional[PostgresDsn] = None
41 |
42 | @validator("SQLALCHEMY_DATABASE_URI", pre=True)
43 | def assemble_db_connection(cls, v: Optional[str], values: Dict[str, Any]) -> Any:
44 | if isinstance(v, str):
45 | return v
46 | return PostgresDsn.build(
47 | scheme="postgresql",
48 | user=values.get("POSTGRES_USER"),
49 | password=values.get("POSTGRES_PASSWORD"),
50 | host=values.get("POSTGRES_SERVER"),
51 | path=f"/{values.get('POSTGRES_DB') or ''}",
52 | )
53 |
54 | SMTP_TLS: bool = True
55 | SMTP_PORT: Optional[int] = None
56 | SMTP_HOST: Optional[str] = None
57 | SMTP_USER: Optional[str] = None
58 | SMTP_PASSWORD: Optional[str] = None
59 | EMAILS_FROM_EMAIL: Optional[EmailStr] = None
60 | EMAILS_FROM_NAME: Optional[str] = None
61 |
62 | @validator("EMAILS_FROM_NAME")
63 | def get_project_name(cls, v: Optional[str], values: Dict[str, Any]) -> str:
64 | if not v:
65 | return values["PROJECT_NAME"]
66 | return v
67 |
68 | EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48
69 | EMAIL_TEMPLATES_DIR: str = "/app/app/email-templates/build"
70 | EMAILS_ENABLED: bool = False
71 |
72 | @validator("EMAILS_ENABLED", pre=True)
73 | def get_emails_enabled(cls, v: bool, values: Dict[str, Any]) -> bool:
74 | return bool(
75 | values.get("SMTP_HOST")
76 | and values.get("SMTP_PORT")
77 | and values.get("EMAILS_FROM_EMAIL")
78 | )
79 |
80 | EMAIL_TEST_USER: EmailStr = "test@example.com" # type: ignore
81 | FIRST_SUPERUSER: EmailStr
82 | FIRST_SUPERUSER_PASSWORD: str
83 | USERS_OPEN_REGISTRATION: bool = False
84 |
85 | class Config:
86 | case_sensitive = True
87 |
88 |
89 | settings = Settings()
90 |
--------------------------------------------------------------------------------
/{{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 |
7 | from app.core.config import settings
8 |
9 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
10 |
11 |
12 | ALGORITHM = "HS256"
13 |
14 |
15 | def create_access_token(
16 | subject: Union[str, Any], expires_delta: timedelta = None
17 | ) -> str:
18 | if expires_delta:
19 | expire = datetime.utcnow() + expires_delta
20 | else:
21 | expire = datetime.utcnow() + timedelta(
22 | minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
23 | )
24 | to_encode = {"exp": expire, "sub": str(subject)}
25 | encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
26 | return encoded_jwt
27 |
28 |
29 | def verify_password(plain_password: str, hashed_password: str) -> bool:
30 | return pwd_context.verify(plain_password, hashed_password)
31 |
32 |
33 | def get_password_hash(password: str) -> str:
34 | return pwd_context.hash(password)
35 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py:
--------------------------------------------------------------------------------
1 | from .crud_item import item
2 | from .crud_user import user
3 |
4 | # For a new basic set of CRUD operations you could just do
5 |
6 | # from .base import CRUDBase
7 | # from app.models.item import Item
8 | # from app.schemas.item import ItemCreate, ItemUpdate
9 |
10 | # item = CRUDBase[Item, ItemCreate, ItemUpdate](Item)
11 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/crud/base.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union
2 |
3 | from fastapi.encoders import jsonable_encoder
4 | from pydantic import BaseModel
5 | from sqlalchemy.orm import Session
6 |
7 | from app.db.base_class import Base
8 |
9 | ModelType = TypeVar("ModelType", bound=Base)
10 | CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
11 | UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
12 |
13 |
14 | class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
15 | def __init__(self, model: Type[ModelType]):
16 | """
17 | CRUD object with default methods to Create, Read, Update, Delete (CRUD).
18 |
19 | **Parameters**
20 |
21 | * `model`: A SQLAlchemy model class
22 | * `schema`: A Pydantic model (schema) class
23 | """
24 | self.model = model
25 |
26 | def get(self, db: Session, id: Any) -> Optional[ModelType]:
27 | return db.query(self.model).filter(self.model.id == id).first()
28 |
29 | def get_multi(
30 | self, db: Session, *, skip: int = 0, limit: int = 100
31 | ) -> List[ModelType]:
32 | return db.query(self.model).offset(skip).limit(limit).all()
33 |
34 | def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType:
35 | obj_in_data = jsonable_encoder(obj_in)
36 | db_obj = self.model(**obj_in_data) # type: ignore
37 | db.add(db_obj)
38 | db.commit()
39 | db.refresh(db_obj)
40 | return db_obj
41 |
42 | def update(
43 | self,
44 | db: Session,
45 | *,
46 | db_obj: ModelType,
47 | obj_in: Union[UpdateSchemaType, Dict[str, Any]]
48 | ) -> ModelType:
49 | obj_data = jsonable_encoder(db_obj)
50 | if isinstance(obj_in, dict):
51 | update_data = obj_in
52 | else:
53 | update_data = obj_in.dict(exclude_unset=True)
54 | for field in obj_data:
55 | if field in update_data:
56 | setattr(db_obj, field, update_data[field])
57 | db.add(db_obj)
58 | db.commit()
59 | db.refresh(db_obj)
60 | return db_obj
61 |
62 | def remove(self, db: Session, *, id: int) -> ModelType:
63 | obj = db.query(self.model).get(id)
64 | db.delete(obj)
65 | db.commit()
66 | return obj
67 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/crud/crud_item.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 | from fastapi.encoders import jsonable_encoder
4 | from sqlalchemy.orm import Session
5 |
6 | from app.crud.base import CRUDBase
7 | from app.models.item import Item
8 | from app.schemas.item import ItemCreate, ItemUpdate
9 |
10 |
11 | class CRUDItem(CRUDBase[Item, ItemCreate, ItemUpdate]):
12 | def create_with_owner(
13 | self, db: Session, *, obj_in: ItemCreate, owner_id: int
14 | ) -> Item:
15 | obj_in_data = jsonable_encoder(obj_in)
16 | db_obj = self.model(**obj_in_data, owner_id=owner_id)
17 | db.add(db_obj)
18 | db.commit()
19 | db.refresh(db_obj)
20 | return db_obj
21 |
22 | def get_multi_by_owner(
23 | self, db: Session, *, owner_id: int, skip: int = 0, limit: int = 100
24 | ) -> List[Item]:
25 | return (
26 | db.query(self.model)
27 | .filter(Item.owner_id == owner_id)
28 | .offset(skip)
29 | .limit(limit)
30 | .all()
31 | )
32 |
33 |
34 | item = CRUDItem(Item)
35 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/crud/crud_user.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Dict, Optional, Union
2 |
3 | from sqlalchemy.orm import Session
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, UserUpdate
9 |
10 |
11 | class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]):
12 | def get_by_email(self, db: Session, *, email: str) -> Optional[User]:
13 | return db.query(User).filter(User.email == email).first()
14 |
15 | def create(self, db: Session, *, obj_in: UserCreate) -> User:
16 | db_obj = User(
17 | email=obj_in.email,
18 | hashed_password=get_password_hash(obj_in.password),
19 | full_name=obj_in.full_name,
20 | is_superuser=obj_in.is_superuser,
21 | )
22 | db.add(db_obj)
23 | db.commit()
24 | db.refresh(db_obj)
25 | return db_obj
26 |
27 | def update(
28 | self, db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]]
29 | ) -> User:
30 | if isinstance(obj_in, dict):
31 | update_data = obj_in
32 | else:
33 | update_data = obj_in.dict(exclude_unset=True)
34 | if update_data["password"]:
35 | hashed_password = get_password_hash(update_data["password"])
36 | del update_data["password"]
37 | update_data["hashed_password"] = hashed_password
38 | return super().update(db, db_obj=db_obj, obj_in=update_data)
39 |
40 | def authenticate(self, db: Session, *, email: str, password: str) -> Optional[User]:
41 | user = self.get_by_email(db, email=email)
42 | if not user:
43 | return None
44 | if not verify_password(password, user.hashed_password):
45 | return None
46 | return user
47 |
48 | def is_active(self, user: User) -> bool:
49 | return user.is_active
50 |
51 | def is_superuser(self, user: User) -> bool:
52 | return user.is_superuser
53 |
54 |
55 | user = CRUDUser(User)
56 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/db/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{cookiecutter.project_slug}}/backend/app/app/db/__init__.py
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/db/base.py:
--------------------------------------------------------------------------------
1 | # Import all the models, so that Base has them before being
2 | # imported by Alembic
3 | from app.db.base_class import Base # noqa
4 | from app.models.item import Item # noqa
5 | from app.models.user import User # noqa
6 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/db/base_class.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from sqlalchemy.ext.declarative import as_declarative, declared_attr
4 |
5 |
6 | @as_declarative()
7 | class Base:
8 | id: Any
9 | __name__: str
10 | # Generate __tablename__ automatically
11 | @declared_attr
12 | def __tablename__(cls) -> str:
13 | return cls.__name__.lower()
14 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/db/init_db.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy.orm import Session
2 |
3 | from app import crud, schemas
4 | from app.core.config import settings
5 | from app.db import base # noqa: F401
6 |
7 | # make sure all SQL Alchemy models are imported (app.db.base) before initializing DB
8 | # otherwise, SQL Alchemy might fail to initialize relationships properly
9 | # for more details: https://github.com/tiangolo/full-stack-fastapi-postgresql/issues/28
10 |
11 |
12 | def init_db(db: Session) -> None:
13 | # Tables should be created with Alembic migrations
14 | # But if you don't want to use migrations, create
15 | # the tables un-commenting the next line
16 | # Base.metadata.create_all(bind=engine)
17 |
18 | user = crud.user.get_by_email(db, email=settings.FIRST_SUPERUSER)
19 | if not user:
20 | user_in = schemas.UserCreate(
21 | email=settings.FIRST_SUPERUSER,
22 | password=settings.FIRST_SUPERUSER_PASSWORD,
23 | is_superuser=True,
24 | )
25 | user = crud.user.create(db, obj_in=user_in) # noqa: F841
26 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/db/session.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import create_engine
2 | from sqlalchemy.orm import sessionmaker
3 |
4 | from app.core.config import settings
5 |
6 | engine = create_engine(settings.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True)
7 | SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
8 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/email-templates/build/new_account.html:
--------------------------------------------------------------------------------
1 |
| {{ project_name }} - New Account | You have a new account: | Username: {{ username }} | Password: {{ password }} | | |
|
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/email-templates/build/reset_password.html:
--------------------------------------------------------------------------------
1 | | {{ project_name }} - Password Recovery | We received a request to recover the password for user {{ username }} with email {{ email }} | Reset your password by clicking the button below: | | Or open the following link: | | | The reset password link / button will expire in {{ valid_hours }} hours. | If you didn't request a password recovery you can disregard this email. |
|
--------------------------------------------------------------------------------
/{{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/src/new_account.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ project_name }} - New Account
7 | You have a new account:
8 | Username: {{ username }}
9 | Password: {{ password }}
10 | Go to Dashboard
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/email-templates/src/reset_password.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ project_name }} - Password Recovery
7 | We received a request to recover the password for user {{ username }}
8 | with email {{ email }}
9 | Reset your password by clicking the button below:
10 | Reset Password
11 | Or open the following link:
12 | {{ link }}
13 |
14 | The reset password link / button will expire in {{ valid_hours }} hours.
15 | If you didn't request a password recovery you can disregard this email.
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/{{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/initial_data.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from app.db.init_db import init_db
4 | from app.db.session import SessionLocal
5 |
6 | logging.basicConfig(level=logging.INFO)
7 | logger = logging.getLogger(__name__)
8 |
9 |
10 | def init() -> None:
11 | db = SessionLocal()
12 | init_db(db)
13 |
14 |
15 | def main() -> None:
16 | logger.info("Creating initial data")
17 | init()
18 | logger.info("Initial data created")
19 |
20 |
21 | if __name__ == "__main__":
22 | main()
23 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/main.py:
--------------------------------------------------------------------------------
1 | from fastapi import FastAPI
2 | from starlette.middleware.cors import CORSMiddleware
3 |
4 | from app.api.api_v1.api import api_router
5 | from app.core.config import settings
6 |
7 | app = FastAPI(
8 | title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json"
9 | )
10 |
11 | # Set all CORS enabled origins
12 | if settings.BACKEND_CORS_ORIGINS:
13 | app.add_middleware(
14 | CORSMiddleware,
15 | allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
16 | allow_credentials=True,
17 | allow_methods=["*"],
18 | allow_headers=["*"],
19 | )
20 |
21 | app.include_router(api_router, prefix=settings.API_V1_STR)
22 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/models/__init__.py:
--------------------------------------------------------------------------------
1 | from .item import Item
2 | from .user import User
3 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/models/item.py:
--------------------------------------------------------------------------------
1 | from typing import TYPE_CHECKING
2 |
3 | from sqlalchemy import Column, ForeignKey, Integer, String
4 | from sqlalchemy.orm import relationship
5 |
6 | from app.db.base_class import Base
7 |
8 | if TYPE_CHECKING:
9 | from .user import User # noqa: F401
10 |
11 |
12 | class Item(Base):
13 | id = Column(Integer, primary_key=True, index=True)
14 | title = Column(String, index=True)
15 | description = Column(String, index=True)
16 | owner_id = Column(Integer, ForeignKey("user.id"))
17 | owner = relationship("User", back_populates="items")
18 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/models/user.py:
--------------------------------------------------------------------------------
1 | from typing import TYPE_CHECKING
2 |
3 | from sqlalchemy import Boolean, Column, Integer, String
4 | from sqlalchemy.orm import relationship
5 |
6 | from app.db.base_class import Base
7 |
8 | if TYPE_CHECKING:
9 | from .item import Item # noqa: F401
10 |
11 |
12 | class User(Base):
13 | id = Column(Integer, primary_key=True, index=True)
14 | full_name = Column(String, index=True)
15 | email = Column(String, unique=True, index=True, nullable=False)
16 | hashed_password = Column(String, nullable=False)
17 | is_active = Column(Boolean(), default=True)
18 | is_superuser = Column(Boolean(), default=False)
19 | items = relationship("Item", back_populates="owner")
20 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/schemas/__init__.py:
--------------------------------------------------------------------------------
1 | from .item import Item, ItemCreate, ItemInDB, ItemUpdate
2 | from .msg import Msg
3 | from .token import Token, TokenPayload
4 | from .user import User, UserCreate, UserInDB, UserUpdate
5 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/schemas/item.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from pydantic import BaseModel
4 |
5 |
6 | # Shared properties
7 | class ItemBase(BaseModel):
8 | title: Optional[str] = None
9 | description: Optional[str] = None
10 |
11 |
12 | # Properties to receive on item creation
13 | class ItemCreate(ItemBase):
14 | title: str
15 |
16 |
17 | # Properties to receive on item update
18 | class ItemUpdate(ItemBase):
19 | pass
20 |
21 |
22 | # Properties shared by models stored in DB
23 | class ItemInDBBase(ItemBase):
24 | id: int
25 | title: str
26 | owner_id: int
27 |
28 | class Config:
29 | orm_mode = True
30 |
31 |
32 | # Properties to return to client
33 | class Item(ItemInDBBase):
34 | pass
35 |
36 |
37 | # Properties properties stored in DB
38 | class ItemInDB(ItemInDBBase):
39 | pass
40 |
--------------------------------------------------------------------------------
/{{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 typing import Optional
2 |
3 | from pydantic import BaseModel
4 |
5 |
6 | class Token(BaseModel):
7 | access_token: str
8 | token_type: str
9 |
10 |
11 | class TokenPayload(BaseModel):
12 | sub: Optional[int] = None
13 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/schemas/user.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from pydantic import BaseModel, EmailStr
4 |
5 |
6 | # Shared properties
7 | class UserBase(BaseModel):
8 | email: Optional[EmailStr] = None
9 | is_active: Optional[bool] = True
10 | is_superuser: bool = False
11 | full_name: Optional[str] = None
12 |
13 |
14 | # Properties to receive via API on creation
15 | class UserCreate(UserBase):
16 | email: EmailStr
17 | password: str
18 |
19 |
20 | # Properties to receive via API on update
21 | class UserUpdate(UserBase):
22 | password: Optional[str] = None
23 |
24 |
25 | class UserInDBBase(UserBase):
26 | id: Optional[int] = None
27 |
28 | class Config:
29 | orm_mode = True
30 |
31 |
32 | # Additional properties to return via API
33 | class User(UserInDBBase):
34 | pass
35 |
36 |
37 | # Additional properties stored in DB
38 | class UserInDB(UserInDBBase):
39 | hashed_password: str
40 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/tests/.gitignore:
--------------------------------------------------------------------------------
1 | .cache
2 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{cookiecutter.project_slug}}/backend/app/app/tests/__init__.py
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/tests/api/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{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/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/__init__.py
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_celery.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_celery_worker_test(
9 | client: TestClient, superuser_token_headers: Dict[str, str]
10 | ) -> None:
11 | data = {"msg": "test"}
12 | r = client.post(
13 | f"{settings.API_V1_STR}/utils/test-celery/",
14 | json=data,
15 | headers=superuser_token_headers,
16 | )
17 | response = r.json()
18 | assert response["msg"] == "Word received"
19 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_items.py:
--------------------------------------------------------------------------------
1 | from fastapi.testclient import TestClient
2 | from sqlalchemy.orm import Session
3 |
4 | from app.core.config import settings
5 | from app.tests.utils.item import create_random_item
6 |
7 |
8 | def test_create_item(
9 | client: TestClient, superuser_token_headers: dict, db: Session
10 | ) -> None:
11 | data = {"title": "Foo", "description": "Fighters"}
12 | response = client.post(
13 | f"{settings.API_V1_STR}/items/", headers=superuser_token_headers, json=data,
14 | )
15 | assert response.status_code == 200
16 | content = response.json()
17 | assert content["title"] == data["title"]
18 | assert content["description"] == data["description"]
19 | assert "id" in content
20 | assert "owner_id" in content
21 |
22 |
23 | def test_read_item(
24 | client: TestClient, superuser_token_headers: dict, db: Session
25 | ) -> None:
26 | item = create_random_item(db)
27 | response = client.get(
28 | f"{settings.API_V1_STR}/items/{item.id}", headers=superuser_token_headers,
29 | )
30 | assert response.status_code == 200
31 | content = response.json()
32 | assert content["title"] == item.title
33 | assert content["description"] == item.description
34 | assert content["id"] == item.id
35 | assert content["owner_id"] == item.owner_id
36 |
--------------------------------------------------------------------------------
/{{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/access-token", 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(
21 | client: TestClient, superuser_token_headers: Dict[str, str]
22 | ) -> None:
23 | r = client.post(
24 | f"{settings.API_V1_STR}/login/test-token", headers=superuser_token_headers,
25 | )
26 | result = r.json()
27 | assert r.status_code == 200
28 | assert "email" in result
29 |
--------------------------------------------------------------------------------
/{{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 sqlalchemy.orm import Session
5 |
6 | from app import crud
7 | from app.core.config import settings
8 | from app.schemas.user import UserCreate
9 | from app.tests.utils.utils import random_email, random_lower_string
10 |
11 |
12 | def test_get_users_superuser_me(
13 | client: TestClient, superuser_token_headers: Dict[str, str]
14 | ) -> None:
15 | r = client.get(f"{settings.API_V1_STR}/users/me", 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 | def test_get_users_normal_user_me(
24 | client: TestClient, normal_user_token_headers: Dict[str, str]
25 | ) -> None:
26 | r = client.get(f"{settings.API_V1_STR}/users/me", headers=normal_user_token_headers)
27 | current_user = r.json()
28 | assert current_user
29 | assert current_user["is_active"] is True
30 | assert current_user["is_superuser"] is False
31 | assert current_user["email"] == settings.EMAIL_TEST_USER
32 |
33 |
34 | def test_create_user_new_email(
35 | client: TestClient, superuser_token_headers: dict, db: Session
36 | ) -> None:
37 | username = random_email()
38 | password = random_lower_string()
39 | data = {"email": username, "password": password}
40 | r = client.post(
41 | f"{settings.API_V1_STR}/users/", headers=superuser_token_headers, json=data,
42 | )
43 | assert 200 <= r.status_code < 300
44 | created_user = r.json()
45 | user = crud.user.get_by_email(db, email=username)
46 | assert user
47 | assert user.email == created_user["email"]
48 |
49 |
50 | def test_get_existing_user(
51 | client: TestClient, superuser_token_headers: dict, db: Session
52 | ) -> None:
53 | username = random_email()
54 | password = random_lower_string()
55 | user_in = UserCreate(email=username, password=password)
56 | user = crud.user.create(db, obj_in=user_in)
57 | user_id = user.id
58 | r = client.get(
59 | f"{settings.API_V1_STR}/users/{user_id}", headers=superuser_token_headers,
60 | )
61 | assert 200 <= r.status_code < 300
62 | api_user = r.json()
63 | existing_user = crud.user.get_by_email(db, email=username)
64 | assert existing_user
65 | assert existing_user.email == api_user["email"]
66 |
67 |
68 | def test_create_user_existing_username(
69 | client: TestClient, superuser_token_headers: dict, db: Session
70 | ) -> None:
71 | username = random_email()
72 | # username = email
73 | password = random_lower_string()
74 | user_in = UserCreate(email=username, password=password)
75 | crud.user.create(db, obj_in=user_in)
76 | data = {"email": username, "password": password}
77 | r = client.post(
78 | f"{settings.API_V1_STR}/users/", headers=superuser_token_headers, json=data,
79 | )
80 | created_user = r.json()
81 | assert r.status_code == 400
82 | assert "_id" not in created_user
83 |
84 |
85 | def test_create_user_by_normal_user(
86 | client: TestClient, normal_user_token_headers: Dict[str, str]
87 | ) -> None:
88 | username = random_email()
89 | password = random_lower_string()
90 | data = {"email": username, "password": password}
91 | r = client.post(
92 | f"{settings.API_V1_STR}/users/", headers=normal_user_token_headers, json=data,
93 | )
94 | assert r.status_code == 400
95 |
96 |
97 | def test_retrieve_users(
98 | client: TestClient, superuser_token_headers: dict, db: Session
99 | ) -> None:
100 | username = random_email()
101 | password = random_lower_string()
102 | user_in = UserCreate(email=username, password=password)
103 | crud.user.create(db, obj_in=user_in)
104 |
105 | username2 = random_email()
106 | password2 = random_lower_string()
107 | user_in2 = UserCreate(email=username2, password=password2)
108 | crud.user.create(db, obj_in=user_in2)
109 |
110 | r = client.get(f"{settings.API_V1_STR}/users/", headers=superuser_token_headers)
111 | all_users = r.json()
112 |
113 | assert len(all_users) > 1
114 | for item in all_users:
115 | assert "email" in item
116 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/tests/conftest.py:
--------------------------------------------------------------------------------
1 | from typing import Dict, Generator
2 |
3 | import pytest
4 | from fastapi.testclient import TestClient
5 | from sqlalchemy.orm import Session
6 |
7 | from app.core.config import settings
8 | from app.db.session import SessionLocal
9 | from app.main import app
10 | from app.tests.utils.user import authentication_token_from_email
11 | from app.tests.utils.utils import get_superuser_token_headers
12 |
13 |
14 | @pytest.fixture(scope="session")
15 | def db() -> Generator:
16 | yield SessionLocal()
17 |
18 |
19 | @pytest.fixture(scope="module")
20 | def client() -> Generator:
21 | with TestClient(app) as c:
22 | yield c
23 |
24 |
25 | @pytest.fixture(scope="module")
26 | def superuser_token_headers(client: TestClient) -> Dict[str, str]:
27 | return get_superuser_token_headers(client)
28 |
29 |
30 | @pytest.fixture(scope="module")
31 | def normal_user_token_headers(client: TestClient, db: Session) -> Dict[str, str]:
32 | return authentication_token_from_email(
33 | client=client, email=settings.EMAIL_TEST_USER, db=db
34 | )
35 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/__init__.py
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_item.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy.orm import Session
2 |
3 | from app import crud
4 | from app.schemas.item import ItemCreate, ItemUpdate
5 | from app.tests.utils.user import create_random_user
6 | from app.tests.utils.utils import random_lower_string
7 |
8 |
9 | def test_create_item(db: Session) -> None:
10 | title = random_lower_string()
11 | description = random_lower_string()
12 | item_in = ItemCreate(title=title, description=description)
13 | user = create_random_user(db)
14 | item = crud.item.create_with_owner(db=db, obj_in=item_in, owner_id=user.id)
15 | assert item.title == title
16 | assert item.description == description
17 | assert item.owner_id == user.id
18 |
19 |
20 | def test_get_item(db: Session) -> None:
21 | title = random_lower_string()
22 | description = random_lower_string()
23 | item_in = ItemCreate(title=title, description=description)
24 | user = create_random_user(db)
25 | item = crud.item.create_with_owner(db=db, obj_in=item_in, owner_id=user.id)
26 | stored_item = crud.item.get(db=db, id=item.id)
27 | assert stored_item
28 | assert item.id == stored_item.id
29 | assert item.title == stored_item.title
30 | assert item.description == stored_item.description
31 | assert item.owner_id == stored_item.owner_id
32 |
33 |
34 | def test_update_item(db: Session) -> None:
35 | title = random_lower_string()
36 | description = random_lower_string()
37 | item_in = ItemCreate(title=title, description=description)
38 | user = create_random_user(db)
39 | item = crud.item.create_with_owner(db=db, obj_in=item_in, owner_id=user.id)
40 | description2 = random_lower_string()
41 | item_update = ItemUpdate(description=description2)
42 | item2 = crud.item.update(db=db, db_obj=item, obj_in=item_update)
43 | assert item.id == item2.id
44 | assert item.title == item2.title
45 | assert item2.description == description2
46 | assert item.owner_id == item2.owner_id
47 |
48 |
49 | def test_delete_item(db: Session) -> None:
50 | title = random_lower_string()
51 | description = random_lower_string()
52 | item_in = ItemCreate(title=title, description=description)
53 | user = create_random_user(db)
54 | item = crud.item.create_with_owner(db=db, obj_in=item_in, owner_id=user.id)
55 | item2 = crud.item.remove(db=db, id=item.id)
56 | item3 = crud.item.get(db=db, id=item.id)
57 | assert item3 is None
58 | assert item2.id == item.id
59 | assert item2.title == title
60 | assert item2.description == description
61 | assert item2.owner_id == user.id
62 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_user.py:
--------------------------------------------------------------------------------
1 | from fastapi.encoders import jsonable_encoder
2 | from sqlalchemy.orm import Session
3 |
4 | from app import crud
5 | from app.core.security import verify_password
6 | from app.schemas.user import UserCreate, UserUpdate
7 | from app.tests.utils.utils import random_email, random_lower_string
8 |
9 |
10 | def test_create_user(db: Session) -> None:
11 | email = random_email()
12 | password = random_lower_string()
13 | user_in = UserCreate(email=email, password=password)
14 | user = crud.user.create(db, obj_in=user_in)
15 | assert user.email == email
16 | assert hasattr(user, "hashed_password")
17 |
18 |
19 | def test_authenticate_user(db: Session) -> None:
20 | email = random_email()
21 | password = random_lower_string()
22 | user_in = UserCreate(email=email, password=password)
23 | user = crud.user.create(db, obj_in=user_in)
24 | authenticated_user = crud.user.authenticate(db, email=email, password=password)
25 | assert authenticated_user
26 | assert user.email == authenticated_user.email
27 |
28 |
29 | def test_not_authenticate_user(db: Session) -> None:
30 | email = random_email()
31 | password = random_lower_string()
32 | user = crud.user.authenticate(db, email=email, password=password)
33 | assert user is None
34 |
35 |
36 | def test_check_if_user_is_active(db: Session) -> None:
37 | email = random_email()
38 | password = random_lower_string()
39 | user_in = UserCreate(email=email, password=password)
40 | user = crud.user.create(db, obj_in=user_in)
41 | is_active = crud.user.is_active(user)
42 | assert is_active is True
43 |
44 |
45 | def test_check_if_user_is_active_inactive(db: Session) -> None:
46 | email = random_email()
47 | password = random_lower_string()
48 | user_in = UserCreate(email=email, password=password, disabled=True)
49 | user = crud.user.create(db, obj_in=user_in)
50 | is_active = crud.user.is_active(user)
51 | assert is_active
52 |
53 |
54 | def test_check_if_user_is_superuser(db: Session) -> None:
55 | email = random_email()
56 | password = random_lower_string()
57 | user_in = UserCreate(email=email, password=password, is_superuser=True)
58 | user = crud.user.create(db, obj_in=user_in)
59 | is_superuser = crud.user.is_superuser(user)
60 | assert is_superuser is True
61 |
62 |
63 | def test_check_if_user_is_superuser_normal_user(db: Session) -> None:
64 | username = random_email()
65 | password = random_lower_string()
66 | user_in = UserCreate(email=username, password=password)
67 | user = crud.user.create(db, obj_in=user_in)
68 | is_superuser = crud.user.is_superuser(user)
69 | assert is_superuser is False
70 |
71 |
72 | def test_get_user(db: Session) -> None:
73 | password = random_lower_string()
74 | username = random_email()
75 | user_in = UserCreate(email=username, password=password, is_superuser=True)
76 | user = crud.user.create(db, obj_in=user_in)
77 | user_2 = crud.user.get(db, id=user.id)
78 | assert user_2
79 | assert user.email == user_2.email
80 | assert jsonable_encoder(user) == jsonable_encoder(user_2)
81 |
82 |
83 | def test_update_user(db: Session) -> None:
84 | password = random_lower_string()
85 | email = random_email()
86 | user_in = UserCreate(email=email, password=password, is_superuser=True)
87 | user = crud.user.create(db, obj_in=user_in)
88 | new_password = random_lower_string()
89 | user_in_update = UserUpdate(password=new_password, is_superuser=True)
90 | crud.user.update(db, db_obj=user, obj_in=user_in_update)
91 | user_2 = crud.user.get(db, id=user.id)
92 | assert user_2
93 | assert user.email == user_2.email
94 | assert verify_password(new_password, user_2.hashed_password)
95 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/__init__.py
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/item.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from sqlalchemy.orm import Session
4 |
5 | from app import crud, models
6 | from app.schemas.item import ItemCreate
7 | from app.tests.utils.user import create_random_user
8 | from app.tests.utils.utils import random_lower_string
9 |
10 |
11 | def create_random_item(db: Session, *, owner_id: Optional[int] = None) -> models.Item:
12 | if owner_id is None:
13 | user = create_random_user(db)
14 | owner_id = user.id
15 | title = random_lower_string()
16 | description = random_lower_string()
17 | item_in = ItemCreate(title=title, description=description, id=id)
18 | return crud.item.create_with_owner(db=db, obj_in=item_in, owner_id=owner_id)
19 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/user.py:
--------------------------------------------------------------------------------
1 | from typing import Dict
2 |
3 | from fastapi.testclient import TestClient
4 | from sqlalchemy.orm import Session
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(
14 | *, client: TestClient, email: str, password: str
15 | ) -> Dict[str, str]:
16 | data = {"username": email, "password": password}
17 |
18 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=data)
19 | response = r.json()
20 | auth_token = response["access_token"]
21 | headers = {"Authorization": f"Bearer {auth_token}"}
22 | return headers
23 |
24 |
25 | def create_random_user(db: Session) -> User:
26 | email = random_email()
27 | password = random_lower_string()
28 | user_in = UserCreate(username=email, email=email, password=password)
29 | user = crud.user.create(db=db, obj_in=user_in)
30 | return user
31 |
32 |
33 | def authentication_token_from_email(
34 | *, client: TestClient, email: str, db: Session
35 | ) -> Dict[str, str]:
36 | """
37 | Return a valid token for the user with given email.
38 |
39 | If the user doesn't exist it is created first.
40 | """
41 | password = random_lower_string()
42 | user = crud.user.get_by_email(db, email=email)
43 | if not user:
44 | user_in_create = UserCreate(username=email, email=email, password=password)
45 | user = crud.user.create(db, obj_in=user_in_create)
46 | else:
47 | user_in_update = UserUpdate(password=password)
48 | user = crud.user.update(db, db_obj=user, obj_in=user_in_update)
49 |
50 | return user_authentication_headers(client=client, email=email, password=password)
51 |
--------------------------------------------------------------------------------
/{{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/access-token", 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 logging
2 |
3 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed
4 |
5 | from app.db.session import SessionLocal
6 |
7 | logging.basicConfig(level=logging.INFO)
8 | logger = logging.getLogger(__name__)
9 |
10 | max_tries = 60 * 5 # 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 | def init() -> None:
21 | try:
22 | # Try to create session to check if DB is awake
23 | db = SessionLocal()
24 | db.execute("SELECT 1")
25 | except Exception as e:
26 | logger.error(e)
27 | raise e
28 |
29 |
30 | def main() -> None:
31 | logger.info("Initializing service")
32 | init()
33 | logger.info("Service finished initializing")
34 |
35 |
36 | if __name__ == "__main__":
37 | main()
38 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/utils.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from datetime import datetime, timedelta
3 | from pathlib import Path
4 | from typing import Any, Dict, Optional
5 |
6 | import emails
7 | from emails.template import JinjaTemplate
8 | from jose import jwt
9 |
10 | from app.core.config import settings
11 |
12 |
13 | def send_email(
14 | email_to: str,
15 | subject_template: str = "",
16 | html_template: str = "",
17 | environment: Dict[str, Any] = {},
18 | ) -> None:
19 | assert settings.EMAILS_ENABLED, "no provided configuration for email variables"
20 | message = emails.Message(
21 | subject=JinjaTemplate(subject_template),
22 | html=JinjaTemplate(html_template),
23 | mail_from=(settings.EMAILS_FROM_NAME, settings.EMAILS_FROM_EMAIL),
24 | )
25 | smtp_options = {"host": settings.SMTP_HOST, "port": settings.SMTP_PORT}
26 | if settings.SMTP_TLS:
27 | smtp_options["tls"] = True
28 | if settings.SMTP_USER:
29 | smtp_options["user"] = settings.SMTP_USER
30 | if settings.SMTP_PASSWORD:
31 | smtp_options["password"] = settings.SMTP_PASSWORD
32 | response = message.send(to=email_to, render=environment, smtp=smtp_options)
33 | logging.info(f"send email result: {response}")
34 |
35 |
36 | def send_test_email(email_to: str) -> None:
37 | project_name = settings.PROJECT_NAME
38 | subject = f"{project_name} - Test email"
39 | with open(Path(settings.EMAIL_TEMPLATES_DIR) / "test_email.html") as f:
40 | template_str = f.read()
41 | send_email(
42 | email_to=email_to,
43 | subject_template=subject,
44 | html_template=template_str,
45 | environment={"project_name": settings.PROJECT_NAME, "email": email_to},
46 | )
47 |
48 |
49 | def send_reset_password_email(email_to: str, email: str, token: str) -> None:
50 | project_name = settings.PROJECT_NAME
51 | subject = f"{project_name} - Password recovery for user {email}"
52 | with open(Path(settings.EMAIL_TEMPLATES_DIR) / "reset_password.html") as f:
53 | template_str = f.read()
54 | server_host = settings.SERVER_HOST
55 | link = f"{server_host}/reset-password?token={token}"
56 | send_email(
57 | email_to=email_to,
58 | subject_template=subject,
59 | html_template=template_str,
60 | environment={
61 | "project_name": settings.PROJECT_NAME,
62 | "username": email,
63 | "email": email_to,
64 | "valid_hours": settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS,
65 | "link": link,
66 | },
67 | )
68 |
69 |
70 | def send_new_account_email(email_to: str, username: str, password: str) -> None:
71 | project_name = settings.PROJECT_NAME
72 | subject = f"{project_name} - New account for user {username}"
73 | with open(Path(settings.EMAIL_TEMPLATES_DIR) / "new_account.html") as f:
74 | template_str = f.read()
75 | link = settings.SERVER_HOST
76 | send_email(
77 | email_to=email_to,
78 | subject_template=subject,
79 | html_template=template_str,
80 | environment={
81 | "project_name": settings.PROJECT_NAME,
82 | "username": username,
83 | "password": password,
84 | "email": email_to,
85 | "link": link,
86 | },
87 | )
88 |
89 |
90 | def generate_password_reset_token(email: str) -> str:
91 | delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS)
92 | now = datetime.utcnow()
93 | expires = now + delta
94 | exp = expires.timestamp()
95 | encoded_jwt = jwt.encode(
96 | {"exp": exp, "nbf": now, "sub": email}, settings.SECRET_KEY, algorithm="HS256",
97 | )
98 | return encoded_jwt
99 |
100 |
101 | def verify_password_reset_token(token: str) -> Optional[str]:
102 | try:
103 | decoded_token = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
104 | return decoded_token["email"]
105 | except jwt.JWTError:
106 | return None
107 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/app/worker.py:
--------------------------------------------------------------------------------
1 | from raven import Client
2 |
3 | from app.core.celery_app import celery_app
4 | from app.core.config import settings
5 |
6 | client_sentry = Client(settings.SENTRY_DSN)
7 |
8 |
9 | @celery_app.task(acks_late=True)
10 | def test_celery(word: str) -> str:
11 | return f"test task return {word}"
12 |
--------------------------------------------------------------------------------
/{{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 | # Run migrations
7 | alembic upgrade head
8 |
9 | # Create initial data in DB
10 | python /app/app/initial_data.py
11 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/app/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "app"
3 | version = "0.1.0"
4 | description = ""
5 | authors = ["Admin "]
6 |
7 | [tool.poetry.dependencies]
8 | python = "^3.7"
9 | uvicorn = "^0.11.3"
10 | fastapi = "^0.54.1"
11 | python-multipart = "^0.0.5"
12 | email-validator = "^1.0.5"
13 | requests = "^2.23.0"
14 | celery = "^4.4.2"
15 | passlib = {extras = ["bcrypt"], version = "^1.7.2"}
16 | tenacity = "^6.1.0"
17 | pydantic = "^1.4"
18 | emails = "^0.5.15"
19 | raven = "^6.10.0"
20 | gunicorn = "^20.0.4"
21 | jinja2 = "^2.11.2"
22 | psycopg2-binary = "^2.8.5"
23 | alembic = "^1.4.2"
24 | sqlalchemy = "^1.3.16"
25 | pytest = "^5.4.1"
26 | python-jose = {extras = ["cryptography"], version = "^3.1.0"}
27 |
28 | [tool.poetry.dev-dependencies]
29 | mypy = "^0.770"
30 | black = "^19.10b0"
31 | isort = "^4.3.21"
32 | autoflake = "^1.3.1"
33 | flake8 = "^3.7.9"
34 | pytest = "^5.4.1"
35 | sqlalchemy-stubs = "^0.3"
36 | pytest-cov = "^2.8.1"
37 |
38 | [tool.isort]
39 | multi_line_output = 3
40 | include_trailing_comma = true
41 | force_grid_wrap = 0
42 | line_length = 88
43 | [build-system]
44 | requires = ["poetry>=0.12"]
45 | build-backend = "poetry.masonry.api"
46 |
47 |
--------------------------------------------------------------------------------
/{{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 | 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 | python /app/app/celeryworker_pre_start.py
5 |
6 | celery worker -A app.worker -l info -Q main-queue -c 1
7 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/backend.dockerfile:
--------------------------------------------------------------------------------
1 | FROM tiangolo/uvicorn-gunicorn-fastapi:python3.7
2 |
3 | WORKDIR /app/
4 |
5 | # Install Poetry
6 | RUN curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | POETRY_HOME=/opt/poetry python && \
7 | cd /usr/local/bin && \
8 | ln -s /opt/poetry/bin/poetry && \
9 | poetry config virtualenvs.create false
10 |
11 | # Copy poetry.lock* in case it doesn't exist in the repo
12 | COPY ./app/pyproject.toml ./app/poetry.lock* /app/
13 |
14 | # Allow installing dev dependencies to run tests
15 | ARG INSTALL_DEV=false
16 | RUN bash -c "if [ $INSTALL_DEV == 'true' ] ; then poetry install --no-root ; else poetry install --no-root --no-dev ; fi"
17 |
18 | # For development, Jupyter remote kernel, Hydrogen
19 | # Using inside the container:
20 | # jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888
21 | ARG INSTALL_JUPYTER=false
22 | RUN bash -c "if [ $INSTALL_JUPYTER == 'true' ] ; then pip install jupyterlab ; fi"
23 |
24 | COPY ./app /app
25 | ENV PYTHONPATH=/app
26 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/backend/celeryworker.dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.7
2 |
3 | WORKDIR /app/
4 |
5 | # Install Poetry
6 | RUN curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | POETRY_HOME=/opt/poetry python && \
7 | cd /usr/local/bin && \
8 | ln -s /opt/poetry/bin/poetry && \
9 | poetry config virtualenvs.create false
10 |
11 | # Copy poetry.lock* in case it doesn't exist in the repo
12 | COPY ./app/pyproject.toml ./app/poetry.lock* /app/
13 |
14 | # Allow installing dev dependencies to run tests
15 | ARG INSTALL_DEV=false
16 | RUN bash -c "if [ $INSTALL_DEV == 'true' ] ; then poetry install --no-root ; else poetry install --no-root --no-dev ; fi"
17 |
18 | # For development, Jupyter remote kernel, Hydrogen
19 | # Using inside the container:
20 | # jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888
21 | ARG INSTALL_JUPYTER=false
22 | RUN bash -c "if [ $INSTALL_JUPYTER == 'true' ] ; then pip install jupyterlab ; fi"
23 |
24 | ENV C_FORCE_ROOT=1
25 |
26 | COPY ./app /app
27 | WORKDIR /app
28 |
29 | ENV PYTHONPATH=/app
30 |
31 | COPY ./app/worker-start.sh /worker-start.sh
32 |
33 | RUN chmod +x /worker-start.sh
34 |
35 | CMD ["bash", "/worker-start.sh"]
36 |
--------------------------------------------------------------------------------
/{{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 | docker_swarm_stack_name_main: '{{ cookiecutter.docker_swarm_stack_name_main }}'
7 | docker_swarm_stack_name_staging: '{{ cookiecutter.docker_swarm_stack_name_staging }}'
8 | secret_key: '{{ cookiecutter.secret_key }}'
9 | first_superuser: '{{ cookiecutter.first_superuser }}'
10 | first_superuser_password: '{{ cookiecutter.first_superuser_password }}'
11 | backend_cors_origins: '{{ cookiecutter.backend_cors_origins }}'
12 | smtp_port: '{{ cookiecutter.smtp_port }}'
13 | smtp_host: '{{ cookiecutter.smtp_host }}'
14 | smtp_user: '{{ cookiecutter.smtp_user }}'
15 | smtp_password: '{{ cookiecutter.smtp_password }}'
16 | smtp_emails_from_email: '{{ cookiecutter.smtp_emails_from_email }}'
17 | postgres_password: '{{ cookiecutter.postgres_password }}'
18 | pgadmin_default_user: '{{ cookiecutter.pgadmin_default_user }}'
19 | pgadmin_default_user_password: '{{ cookiecutter.pgadmin_default_user_password }}'
20 | traefik_constraint_tag: '{{ cookiecutter.traefik_constraint_tag }}'
21 | traefik_constraint_tag_staging: '{{ cookiecutter.traefik_constraint_tag_staging }}'
22 | traefik_public_constraint_tag: '{{ cookiecutter.traefik_public_constraint_tag }}'
23 | flower_auth: '{{ cookiecutter.flower_auth }}'
24 | sentry_dsn: '{{ cookiecutter.sentry_dsn }}'
25 | docker_image_prefix: '{{ cookiecutter.docker_image_prefix }}'
26 | docker_image_backend: '{{ cookiecutter.docker_image_backend }}'
27 | docker_image_celeryworker: '{{ cookiecutter.docker_image_celeryworker }}'
28 | docker_image_frontend: '{{ cookiecutter.docker_image_frontend }}'
29 | _copy_without_render: [frontend/src/**/*.html, frontend/src/**/*.vue, frontend/node_modules/*, backend/app/app/email-templates/**]
30 | _template: ./
31 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/docker-compose.override.yml:
--------------------------------------------------------------------------------
1 | version: "3.3"
2 | services:
3 |
4 | proxy:
5 | ports:
6 | - "80:80"
7 | - "8090:8080"
8 | command:
9 | # Enable Docker in Traefik, so that it reads labels from Docker services
10 | - --providers.docker
11 | # Add a constraint to only use services with the label for this stack
12 | # from the env var TRAEFIK_TAG
13 | - --providers.docker.constraints=Label(`traefik.constraint-label-stack`, `${TRAEFIK_TAG?Variable not set}`)
14 | # Do not expose all Docker services, only the ones explicitly exposed
15 | - --providers.docker.exposedbydefault=false
16 | # Disable Docker Swarm mode for local development
17 | # - --providers.docker.swarmmode
18 | # Enable the access log, with HTTP requests
19 | - --accesslog
20 | # Enable the Traefik log, for configurations and errors
21 | - --log
22 | # Enable the Dashboard and API
23 | - --api
24 | # Enable the Dashboard and API in insecure mode for local development
25 | - --api.insecure=true
26 | labels:
27 | - traefik.enable=true
28 | - traefik.http.routers.${STACK_NAME?Variable not set}-traefik-public-http.rule=Host(`${DOMAIN?Variable not set}`)
29 | - traefik.http.services.${STACK_NAME?Variable not set}-traefik-public.loadbalancer.server.port=80
30 |
31 | pgadmin:
32 | ports:
33 | - "5050:5050"
34 |
35 | flower:
36 | ports:
37 | - "5555:5555"
38 |
39 | backend:
40 | ports:
41 | - "8888:8888"
42 | volumes:
43 | - ./backend/app:/app
44 | environment:
45 | - JUPYTER=jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888
46 | - SERVER_HOST=http://${DOMAIN?Variable not set}
47 | build:
48 | context: ./backend
49 | dockerfile: backend.dockerfile
50 | args:
51 | INSTALL_DEV: ${INSTALL_DEV-true}
52 | INSTALL_JUPYTER: ${INSTALL_JUPYTER-true}
53 | # command: bash -c "while true; do sleep 1; done" # Infinite loop to keep container live doing nothing
54 | command: /start-reload.sh
55 | labels:
56 | - traefik.enable=true
57 | - traefik.constraint-label-stack=${TRAEFIK_TAG?Variable not set}
58 | - traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.rule=PathPrefix(`/api`) || PathPrefix(`/docs`) || PathPrefix(`/redoc`)
59 | - traefik.http.services.${STACK_NAME?Variable not set}-backend.loadbalancer.server.port=80
60 |
61 | celeryworker:
62 | volumes:
63 | - ./backend/app:/app
64 | environment:
65 | - RUN=celery worker -A app.worker -l info -Q main-queue -c 1
66 | - JUPYTER=jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888
67 | - SERVER_HOST=http://${DOMAIN?Variable not set}
68 | build:
69 | context: ./backend
70 | dockerfile: celeryworker.dockerfile
71 | args:
72 | INSTALL_DEV: ${INSTALL_DEV-true}
73 | INSTALL_JUPYTER: ${INSTALL_JUPYTER-true}
74 |
75 | frontend:
76 | build:
77 | context: ./frontend
78 | args:
79 | FRONTEND_ENV: dev
80 | labels:
81 | - traefik.enable=true
82 | - traefik.constraint-label-stack=${TRAEFIK_TAG?Variable not set}
83 | - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.rule=PathPrefix(`/`)
84 | - traefik.http.services.${STACK_NAME?Variable not set}-frontend.loadbalancer.server.port=80
85 |
86 | networks:
87 | traefik-public:
88 | # For local dev, don't expect an external Traefik network
89 | external: false
90 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/.env:
--------------------------------------------------------------------------------
1 | VUE_APP_DOMAIN_DEV=localhost
2 | # VUE_APP_DOMAIN_DEV=local.dockertoolbox.tiangolo.com
3 | # VUE_APP_DOMAIN_DEV=localhost.tiangolo.com
4 | # VUE_APP_DOMAIN_DEV=dev.{{cookiecutter.domain_main}}
5 | VUE_APP_DOMAIN_STAG={{cookiecutter.domain_staging}}
6 | VUE_APP_DOMAIN_PROD={{cookiecutter.domain_main}}
7 | VUE_APP_NAME={{cookiecutter.project_name}}
8 | VUE_APP_ENV=development
9 | # VUE_APP_ENV=staging
10 | # VUE_APP_ENV=production
11 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /dist
4 |
5 | # local env files
6 | .env.local
7 | .env.*.local
8 |
9 | # Log files
10 | npm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 |
14 | # Editor directories and files
15 | .idea
16 | .vscode
17 | *.suo
18 | *.ntvs*
19 | *.njsproj
20 | *.sln
21 | *.sw*
22 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/Dockerfile:
--------------------------------------------------------------------------------
1 | # Stage 0, "build-stage", based on Node.js, to build and compile the frontend
2 | FROM tiangolo/node-frontend:10 as build-stage
3 |
4 | WORKDIR /app
5 |
6 | COPY package*.json /app/
7 |
8 | RUN npm install
9 |
10 | COPY ./ /app/
11 |
12 | ARG FRONTEND_ENV=production
13 |
14 | ENV VUE_APP_ENV=${FRONTEND_ENV}
15 |
16 | # Comment out the next line to disable tests
17 | RUN npm run test:unit
18 |
19 | RUN npm run build
20 |
21 |
22 | # Stage 1, based on Nginx, to have only the compiled app, ready for production with Nginx
23 | FROM nginx:1.15
24 |
25 | COPY --from=build-stage /app/dist/ /usr/share/nginx/html
26 |
27 | COPY --from=build-stage /nginx.conf /etc/nginx/conf.d/default.conf
28 | COPY ./nginx-backend-not-found.conf /etc/nginx/extra-conf.d/backend-not-found.conf
29 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/README.md:
--------------------------------------------------------------------------------
1 | # frontend
2 |
3 | ## Project setup
4 | ```
5 | npm install
6 | ```
7 |
8 | ### Compiles and hot-reloads for development
9 | ```
10 | npm run serve
11 | ```
12 |
13 | ### Compiles and minifies for production
14 | ```
15 | npm run build
16 | ```
17 |
18 | ### Run your tests
19 | ```
20 | npm run test
21 | ```
22 |
23 | ### Lints and fixes files
24 | ```
25 | npm run lint
26 | ```
27 |
28 | ### Run your unit tests
29 | ```
30 | npm run test:unit
31 | ```
32 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "presets": [
3 | [
4 | "@vue/cli-plugin-babel/preset",
5 | {
6 | "useBuiltIns": "entry"
7 | }
8 | ]
9 | ]
10 | }
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/nginx-backend-not-found.conf:
--------------------------------------------------------------------------------
1 | location /api {
2 | return 404;
3 | }
4 | location /docs {
5 | return 404;
6 | }
7 | location /redoc {
8 | return 404;
9 | }
10 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "serve": "vue-cli-service serve",
7 | "build": "vue-cli-service build",
8 | "test:unit": "vue-cli-service test:unit",
9 | "lint": "vue-cli-service lint"
10 | },
11 | "dependencies": {
12 | "@babel/polyfill": "^7.2.5",
13 | "axios": "^0.18.0",
14 | "core-js": "^3.4.3",
15 | "register-service-worker": "^1.0.0",
16 | "typesafe-vuex": "^3.1.1",
17 | "vee-validate": "^2.1.7",
18 | "vue": "^2.5.22",
19 | "vue-class-component": "^6.0.0",
20 | "vue-property-decorator": "^7.3.0",
21 | "vue-router": "^3.0.2",
22 | "vuetify": "^1.4.4",
23 | "vuex": "^3.1.0"
24 | },
25 | "devDependencies": {
26 | "@types/jest": "^23.3.13",
27 | "@vue/cli-plugin-babel": "^4.1.1",
28 | "@vue/cli-plugin-pwa": "^4.1.1",
29 | "@vue/cli-plugin-typescript": "^4.1.1",
30 | "@vue/cli-plugin-unit-jest": "^4.1.1",
31 | "@vue/cli-service": "^4.1.1",
32 | "@vue/test-utils": "^1.0.0-beta.28",
33 | "babel-core": "7.0.0-bridge.0",
34 | "ts-jest": "^23.10.5",
35 | "typescript": "^3.2.4",
36 | "vue-cli-plugin-vuetify": "^2.0.2",
37 | "vue-template-compiler": "^2.5.22"
38 | },
39 | "postcss": {
40 | "plugins": {
41 | "autoprefixer": {}
42 | }
43 | },
44 | "browserslist": [
45 | "> 1%",
46 | "last 2 versions",
47 | "not ie <= 10"
48 | ],
49 | "jest": {
50 | "moduleFileExtensions": [
51 | "js",
52 | "jsx",
53 | "json",
54 | "vue",
55 | "ts",
56 | "tsx"
57 | ],
58 | "transform": {
59 | "^.+\\.vue$": "vue-jest",
60 | ".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$": "jest-transform-stub",
61 | "^.+\\.tsx?$": "ts-jest"
62 | },
63 | "moduleNameMapper": {
64 | "^@/(.*)$": "/src/$1"
65 | },
66 | "snapshotSerializers": [
67 | "jest-serializer-vue"
68 | ],
69 | "testMatch": [
70 | "**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)"
71 | ],
72 | "testURL": "http://localhost/"
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{cookiecutter.project_slug}}/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/public/img/icons/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{cookiecutter.project_slug}}/frontend/public/img/icons/android-chrome-192x192.png
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/public/img/icons/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{cookiecutter.project_slug}}/frontend/public/img/icons/android-chrome-512x512.png
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-120x120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-120x120.png
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-152x152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-152x152.png
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-180x180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-180x180.png
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-60x60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-60x60.png
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-76x76.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-76x76.png
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon.png
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/public/img/icons/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{cookiecutter.project_slug}}/frontend/public/img/icons/favicon-16x16.png
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/public/img/icons/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{cookiecutter.project_slug}}/frontend/public/img/icons/favicon-32x32.png
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/public/img/icons/msapplication-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{cookiecutter.project_slug}}/frontend/public/img/icons/msapplication-icon-144x144.png
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/public/img/icons/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{cookiecutter.project_slug}}/frontend/public/img/icons/mstile-150x150.png
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | <%= VUE_APP_NAME %>
9 |
10 |
11 |
12 |
13 |
14 |
15 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "short_name": "frontend",
4 | "icons": [
5 | {
6 | "src": "/img/icons/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/img/icons/android-chrome-512x512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "start_url": "/",
17 | "display": "standalone",
18 | "background_color": "#000000",
19 | "theme_color": "#4DBA87"
20 | }
21 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow:
3 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
44 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/api.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { apiUrl } from '@/env';
3 | import { IUserProfile, IUserProfileUpdate, IUserProfileCreate } from './interfaces';
4 |
5 | function authHeaders(token: string) {
6 | return {
7 | headers: {
8 | Authorization: `Bearer ${token}`,
9 | },
10 | };
11 | }
12 |
13 | export const api = {
14 | async logInGetToken(username: string, password: string) {
15 | const params = new URLSearchParams();
16 | params.append('username', username);
17 | params.append('password', password);
18 |
19 | return axios.post(`${apiUrl}/api/v1/login/access-token`, params);
20 | },
21 | async getMe(token: string) {
22 | return axios.get(`${apiUrl}/api/v1/users/me`, authHeaders(token));
23 | },
24 | async updateMe(token: string, data: IUserProfileUpdate) {
25 | return axios.put(`${apiUrl}/api/v1/users/me`, data, authHeaders(token));
26 | },
27 | async getUsers(token: string) {
28 | return axios.get(`${apiUrl}/api/v1/users/`, authHeaders(token));
29 | },
30 | async updateUser(token: string, userId: number, data: IUserProfileUpdate) {
31 | return axios.put(`${apiUrl}/api/v1/users/${userId}`, data, authHeaders(token));
32 | },
33 | async createUser(token: string, data: IUserProfileCreate) {
34 | return axios.post(`${apiUrl}/api/v1/users/`, data, authHeaders(token));
35 | },
36 | async passwordRecovery(email: string) {
37 | return axios.post(`${apiUrl}/api/v1/password-recovery/${email}`);
38 | },
39 | async resetPassword(password: string, token: string) {
40 | return axios.post(`${apiUrl}/api/v1/reset-password/`, {
41 | new_password: password,
42 | token,
43 | });
44 | },
45 | };
46 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Dectinc/cookiecutter-fastapi/490c554e23343eec0736b06e59b2108fdd057fdc/{{cookiecutter.project_slug}}/frontend/src/assets/logo.png
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/component-hooks.ts:
--------------------------------------------------------------------------------
1 | import Component from 'vue-class-component';
2 |
3 | // Register the router hooks with their names
4 | Component.registerHooks([
5 | 'beforeRouteEnter',
6 | 'beforeRouteLeave',
7 | 'beforeRouteUpdate', // for vue-router 2.2+
8 | ]);
9 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/components/NotificationsManager.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ currentNotificationContent }}
5 | Close
6 |
7 |
8 |
9 |
78 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/components/RouterComponent.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
12 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/components/UploadButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Choose File
4 |
5 |
6 |
7 |
8 |
25 |
26 |
35 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/env.ts:
--------------------------------------------------------------------------------
1 | const env = process.env.VUE_APP_ENV;
2 |
3 | let envApiUrl = '';
4 |
5 | if (env === 'production') {
6 | envApiUrl = `https://${process.env.VUE_APP_DOMAIN_PROD}`;
7 | } else if (env === 'staging') {
8 | envApiUrl = `https://${process.env.VUE_APP_DOMAIN_STAG}`;
9 | } else {
10 | envApiUrl = `http://${process.env.VUE_APP_DOMAIN_DEV}`;
11 | }
12 |
13 | export const apiUrl = envApiUrl;
14 | export const appName = process.env.VUE_APP_NAME;
15 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/interfaces/index.ts:
--------------------------------------------------------------------------------
1 | export interface IUserProfile {
2 | email: string;
3 | is_active: boolean;
4 | is_superuser: boolean;
5 | full_name: string;
6 | id: number;
7 | }
8 |
9 | export interface IUserProfileUpdate {
10 | email?: string;
11 | full_name?: string;
12 | password?: string;
13 | is_active?: boolean;
14 | is_superuser?: boolean;
15 | }
16 |
17 | export interface IUserProfileCreate {
18 | email: string;
19 | full_name?: string;
20 | password?: string;
21 | is_active?: boolean;
22 | is_superuser?: boolean;
23 | }
24 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/main.ts:
--------------------------------------------------------------------------------
1 | import '@babel/polyfill';
2 | // Import Component hooks before component definitions
3 | import './component-hooks';
4 | import Vue from 'vue';
5 | import './plugins/vuetify';
6 | import './plugins/vee-validate';
7 | import App from './App.vue';
8 | import router from './router';
9 | import store from '@/store';
10 | import './registerServiceWorker';
11 | import 'vuetify/dist/vuetify.min.css';
12 |
13 | Vue.config.productionTip = false;
14 |
15 | new Vue({
16 | router,
17 | store,
18 | render: (h) => h(App),
19 | }).$mount('#app');
20 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/plugins/vee-validate.ts:
--------------------------------------------------------------------------------
1 | import Vue from 'vue';
2 | import VeeValidate from 'vee-validate';
3 |
4 | Vue.use(VeeValidate);
5 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/plugins/vuetify.ts:
--------------------------------------------------------------------------------
1 | import Vue from 'vue';
2 | import Vuetify from 'vuetify';
3 |
4 | Vue.use(Vuetify, {
5 | iconfont: 'md',
6 | });
7 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/registerServiceWorker.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable:no-console */
2 |
3 | import { register } from 'register-service-worker';
4 |
5 | if (process.env.NODE_ENV === 'production') {
6 | register(`${process.env.BASE_URL}service-worker.js`, {
7 | ready() {
8 | console.log(
9 | 'App is being served from cache by a service worker.\n' +
10 | 'For more details, visit https://goo.gl/AFskqB',
11 | );
12 | },
13 | cached() {
14 | console.log('Content has been cached for offline use.');
15 | },
16 | updated() {
17 | console.log('New content is available; please refresh.');
18 | },
19 | offline() {
20 | console.log('No internet connection found. App is running in offline mode.');
21 | },
22 | error(error) {
23 | console.error('Error during service worker registration:', error);
24 | },
25 | });
26 | }
27 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/router.ts:
--------------------------------------------------------------------------------
1 | import Vue from 'vue';
2 | import Router from 'vue-router';
3 |
4 | import RouterComponent from './components/RouterComponent.vue';
5 |
6 | Vue.use(Router);
7 |
8 | export default new Router({
9 | mode: 'history',
10 | base: process.env.BASE_URL,
11 | routes: [
12 | {
13 | path: '/',
14 | component: () => import(/* webpackChunkName: "start" */ './views/main/Start.vue'),
15 | children: [
16 | {
17 | path: 'login',
18 | // route level code-splitting
19 | // this generates a separate chunk (about.[hash].js) for this route
20 | // which is lazy-loaded when the route is visited.
21 | component: () => import(/* webpackChunkName: "login" */ './views/Login.vue'),
22 | },
23 | {
24 | path: 'recover-password',
25 | component: () => import(/* webpackChunkName: "recover-password" */ './views/PasswordRecovery.vue'),
26 | },
27 | {
28 | path: 'reset-password',
29 | component: () => import(/* webpackChunkName: "reset-password" */ './views/ResetPassword.vue'),
30 | },
31 | {
32 | path: 'main',
33 | component: () => import(/* webpackChunkName: "main" */ './views/main/Main.vue'),
34 | children: [
35 | {
36 | path: 'dashboard',
37 | component: () => import(/* webpackChunkName: "main-dashboard" */ './views/main/Dashboard.vue'),
38 | },
39 | {
40 | path: 'profile',
41 | component: RouterComponent,
42 | redirect: 'profile/view',
43 | children: [
44 | {
45 | path: 'view',
46 | component: () => import(
47 | /* webpackChunkName: "main-profile" */ './views/main/profile/UserProfile.vue'),
48 | },
49 | {
50 | path: 'edit',
51 | component: () => import(
52 | /* webpackChunkName: "main-profile-edit" */ './views/main/profile/UserProfileEdit.vue'),
53 | },
54 | {
55 | path: 'password',
56 | component: () => import(
57 | /* webpackChunkName: "main-profile-password" */ './views/main/profile/UserProfileEditPassword.vue'),
58 | },
59 | ],
60 | },
61 | {
62 | path: 'admin',
63 | component: () => import(/* webpackChunkName: "main-admin" */ './views/main/admin/Admin.vue'),
64 | redirect: 'admin/users/all',
65 | children: [
66 | {
67 | path: 'users',
68 | redirect: 'users/all',
69 | },
70 | {
71 | path: 'users/all',
72 | component: () => import(
73 | /* webpackChunkName: "main-admin-users" */ './views/main/admin/AdminUsers.vue'),
74 | },
75 | {
76 | path: 'users/edit/:id',
77 | name: 'main-admin-users-edit',
78 | component: () => import(
79 | /* webpackChunkName: "main-admin-users-edit" */ './views/main/admin/EditUser.vue'),
80 | },
81 | {
82 | path: 'users/create',
83 | name: 'main-admin-users-create',
84 | component: () => import(
85 | /* webpackChunkName: "main-admin-users-create" */ './views/main/admin/CreateUser.vue'),
86 | },
87 | ],
88 | },
89 | ],
90 | },
91 | ],
92 | },
93 | {
94 | path: '/*', redirect: '/',
95 | },
96 | ],
97 | });
98 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/shims-tsx.d.ts:
--------------------------------------------------------------------------------
1 | import Vue, { VNode } from 'vue';
2 |
3 | declare global {
4 | namespace JSX {
5 | // tslint:disable no-empty-interface
6 | interface Element extends VNode {}
7 | // tslint:disable no-empty-interface
8 | interface ElementClass extends Vue {}
9 | interface IntrinsicElements {
10 | [elem: string]: any;
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/shims-vue.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.vue' {
2 | import Vue from 'vue';
3 | export default Vue;
4 | }
5 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/store/admin/actions.ts:
--------------------------------------------------------------------------------
1 | import { api } from '@/api';
2 | import { ActionContext } from 'vuex';
3 | import { IUserProfileCreate, IUserProfileUpdate } from '@/interfaces';
4 | import { State } from '../state';
5 | import { AdminState } from './state';
6 | import { getStoreAccessors } from 'typesafe-vuex';
7 | import { commitSetUsers, commitSetUser } from './mutations';
8 | import { dispatchCheckApiError } from '../main/actions';
9 | import { commitAddNotification, commitRemoveNotification } from '../main/mutations';
10 |
11 | type MainContext = ActionContext;
12 |
13 | export const actions = {
14 | async actionGetUsers(context: MainContext) {
15 | try {
16 | const response = await api.getUsers(context.rootState.main.token);
17 | if (response) {
18 | commitSetUsers(context, response.data);
19 | }
20 | } catch (error) {
21 | await dispatchCheckApiError(context, error);
22 | }
23 | },
24 | async actionUpdateUser(context: MainContext, payload: { id: number, user: IUserProfileUpdate }) {
25 | try {
26 | const loadingNotification = { content: 'saving', showProgress: true };
27 | commitAddNotification(context, loadingNotification);
28 | const response = (await Promise.all([
29 | api.updateUser(context.rootState.main.token, payload.id, payload.user),
30 | await new Promise((resolve, reject) => setTimeout(() => resolve(), 500)),
31 | ]))[0];
32 | commitSetUser(context, response.data);
33 | commitRemoveNotification(context, loadingNotification);
34 | commitAddNotification(context, { content: 'User successfully updated', color: 'success' });
35 | } catch (error) {
36 | await dispatchCheckApiError(context, error);
37 | }
38 | },
39 | async actionCreateUser(context: MainContext, payload: IUserProfileCreate) {
40 | try {
41 | const loadingNotification = { content: 'saving', showProgress: true };
42 | commitAddNotification(context, loadingNotification);
43 | const response = (await Promise.all([
44 | api.createUser(context.rootState.main.token, payload),
45 | await new Promise((resolve, reject) => setTimeout(() => resolve(), 500)),
46 | ]))[0];
47 | commitSetUser(context, response.data);
48 | commitRemoveNotification(context, loadingNotification);
49 | commitAddNotification(context, { content: 'User successfully created', color: 'success' });
50 | } catch (error) {
51 | await dispatchCheckApiError(context, error);
52 | }
53 | },
54 | };
55 |
56 | const { dispatch } = getStoreAccessors('');
57 |
58 | export const dispatchCreateUser = dispatch(actions.actionCreateUser);
59 | export const dispatchGetUsers = dispatch(actions.actionGetUsers);
60 | export const dispatchUpdateUser = dispatch(actions.actionUpdateUser);
61 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/store/admin/getters.ts:
--------------------------------------------------------------------------------
1 | import { AdminState } from './state';
2 | import { getStoreAccessors } from 'typesafe-vuex';
3 | import { State } from '../state';
4 |
5 | export const getters = {
6 | adminUsers: (state: AdminState) => state.users,
7 | adminOneUser: (state: AdminState) => (userId: number) => {
8 | const filteredUsers = state.users.filter((user) => user.id === userId);
9 | if (filteredUsers.length > 0) {
10 | return { ...filteredUsers[0] };
11 | }
12 | },
13 | };
14 |
15 | const { read } = getStoreAccessors('');
16 |
17 | export const readAdminOneUser = read(getters.adminOneUser);
18 | export const readAdminUsers = read(getters.adminUsers);
19 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/store/admin/index.ts:
--------------------------------------------------------------------------------
1 | import { mutations } from './mutations';
2 | import { getters } from './getters';
3 | import { actions } from './actions';
4 | import { AdminState } from './state';
5 |
6 | const defaultState: AdminState = {
7 | users: [],
8 | };
9 |
10 | export const adminModule = {
11 | state: defaultState,
12 | mutations,
13 | actions,
14 | getters,
15 | };
16 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/store/admin/mutations.ts:
--------------------------------------------------------------------------------
1 | import { IUserProfile } from '@/interfaces';
2 | import { AdminState } from './state';
3 | import { getStoreAccessors } from 'typesafe-vuex';
4 | import { State } from '../state';
5 |
6 | export const mutations = {
7 | setUsers(state: AdminState, payload: IUserProfile[]) {
8 | state.users = payload;
9 | },
10 | setUser(state: AdminState, payload: IUserProfile) {
11 | const users = state.users.filter((user: IUserProfile) => user.id !== payload.id);
12 | users.push(payload);
13 | state.users = users;
14 | },
15 | };
16 |
17 | const { commit } = getStoreAccessors('');
18 |
19 | export const commitSetUser = commit(mutations.setUser);
20 | export const commitSetUsers = commit(mutations.setUsers);
21 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/store/admin/state.ts:
--------------------------------------------------------------------------------
1 | import { IUserProfile } from '@/interfaces';
2 |
3 | export interface AdminState {
4 | users: IUserProfile[];
5 | }
6 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/store/index.ts:
--------------------------------------------------------------------------------
1 | import Vue from 'vue';
2 | import Vuex, { StoreOptions } from 'vuex';
3 |
4 | import { mainModule } from './main';
5 | import { State } from './state';
6 | import { adminModule } from './admin';
7 |
8 | Vue.use(Vuex);
9 |
10 | const storeOptions: StoreOptions = {
11 | modules: {
12 | main: mainModule,
13 | admin: adminModule,
14 | },
15 | };
16 |
17 | export const store = new Vuex.Store(storeOptions);
18 |
19 | export default store;
20 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/store/main/actions.ts:
--------------------------------------------------------------------------------
1 | import { api } from '@/api';
2 | import router from '@/router';
3 | import { getLocalToken, removeLocalToken, saveLocalToken } from '@/utils';
4 | import { AxiosError } from 'axios';
5 | import { getStoreAccessors } from 'typesafe-vuex';
6 | import { ActionContext } from 'vuex';
7 | import { State } from '../state';
8 | import {
9 | commitAddNotification,
10 | commitRemoveNotification,
11 | commitSetLoggedIn,
12 | commitSetLogInError,
13 | commitSetToken,
14 | commitSetUserProfile,
15 | } from './mutations';
16 | import { AppNotification, MainState } from './state';
17 |
18 | type MainContext = ActionContext;
19 |
20 | export const actions = {
21 | async actionLogIn(context: MainContext, payload: { username: string; password: string }) {
22 | try {
23 | const response = await api.logInGetToken(payload.username, payload.password);
24 | const token = response.data.access_token;
25 | if (token) {
26 | saveLocalToken(token);
27 | commitSetToken(context, token);
28 | commitSetLoggedIn(context, true);
29 | commitSetLogInError(context, false);
30 | await dispatchGetUserProfile(context);
31 | await dispatchRouteLoggedIn(context);
32 | commitAddNotification(context, { content: 'Logged in', color: 'success' });
33 | } else {
34 | await dispatchLogOut(context);
35 | }
36 | } catch (err) {
37 | commitSetLogInError(context, true);
38 | await dispatchLogOut(context);
39 | }
40 | },
41 | async actionGetUserProfile(context: MainContext) {
42 | try {
43 | const response = await api.getMe(context.state.token);
44 | if (response.data) {
45 | commitSetUserProfile(context, response.data);
46 | }
47 | } catch (error) {
48 | await dispatchCheckApiError(context, error);
49 | }
50 | },
51 | async actionUpdateUserProfile(context: MainContext, payload) {
52 | try {
53 | const loadingNotification = { content: 'saving', showProgress: true };
54 | commitAddNotification(context, loadingNotification);
55 | const response = (await Promise.all([
56 | api.updateMe(context.state.token, payload),
57 | await new Promise((resolve, reject) => setTimeout(() => resolve(), 500)),
58 | ]))[0];
59 | commitSetUserProfile(context, response.data);
60 | commitRemoveNotification(context, loadingNotification);
61 | commitAddNotification(context, { content: 'Profile successfully updated', color: 'success' });
62 | } catch (error) {
63 | await dispatchCheckApiError(context, error);
64 | }
65 | },
66 | async actionCheckLoggedIn(context: MainContext) {
67 | if (!context.state.isLoggedIn) {
68 | let token = context.state.token;
69 | if (!token) {
70 | const localToken = getLocalToken();
71 | if (localToken) {
72 | commitSetToken(context, localToken);
73 | token = localToken;
74 | }
75 | }
76 | if (token) {
77 | try {
78 | const response = await api.getMe(token);
79 | commitSetLoggedIn(context, true);
80 | commitSetUserProfile(context, response.data);
81 | } catch (error) {
82 | await dispatchRemoveLogIn(context);
83 | }
84 | } else {
85 | await dispatchRemoveLogIn(context);
86 | }
87 | }
88 | },
89 | async actionRemoveLogIn(context: MainContext) {
90 | removeLocalToken();
91 | commitSetToken(context, '');
92 | commitSetLoggedIn(context, false);
93 | },
94 | async actionLogOut(context: MainContext) {
95 | await dispatchRemoveLogIn(context);
96 | await dispatchRouteLogOut(context);
97 | },
98 | async actionUserLogOut(context: MainContext) {
99 | await dispatchLogOut(context);
100 | commitAddNotification(context, { content: 'Logged out', color: 'success' });
101 | },
102 | actionRouteLogOut(context: MainContext) {
103 | if (router.currentRoute.path !== '/login') {
104 | router.push('/login');
105 | }
106 | },
107 | async actionCheckApiError(context: MainContext, payload: AxiosError) {
108 | if (payload.response!.status === 401) {
109 | await dispatchLogOut(context);
110 | }
111 | },
112 | actionRouteLoggedIn(context: MainContext) {
113 | if (router.currentRoute.path === '/login' || router.currentRoute.path === '/') {
114 | router.push('/main');
115 | }
116 | },
117 | async removeNotification(context: MainContext, payload: { notification: AppNotification, timeout: number }) {
118 | return new Promise((resolve, reject) => {
119 | setTimeout(() => {
120 | commitRemoveNotification(context, payload.notification);
121 | resolve(true);
122 | }, payload.timeout);
123 | });
124 | },
125 | async passwordRecovery(context: MainContext, payload: { username: string }) {
126 | const loadingNotification = { content: 'Sending password recovery email', showProgress: true };
127 | try {
128 | commitAddNotification(context, loadingNotification);
129 | const response = (await Promise.all([
130 | api.passwordRecovery(payload.username),
131 | await new Promise((resolve, reject) => setTimeout(() => resolve(), 500)),
132 | ]))[0];
133 | commitRemoveNotification(context, loadingNotification);
134 | commitAddNotification(context, { content: 'Password recovery email sent', color: 'success' });
135 | await dispatchLogOut(context);
136 | } catch (error) {
137 | commitRemoveNotification(context, loadingNotification);
138 | commitAddNotification(context, { color: 'error', content: 'Incorrect username' });
139 | }
140 | },
141 | async resetPassword(context: MainContext, payload: { password: string, token: string }) {
142 | const loadingNotification = { content: 'Resetting password', showProgress: true };
143 | try {
144 | commitAddNotification(context, loadingNotification);
145 | const response = (await Promise.all([
146 | api.resetPassword(payload.password, payload.token),
147 | await new Promise((resolve, reject) => setTimeout(() => resolve(), 500)),
148 | ]))[0];
149 | commitRemoveNotification(context, loadingNotification);
150 | commitAddNotification(context, { content: 'Password successfully reset', color: 'success' });
151 | await dispatchLogOut(context);
152 | } catch (error) {
153 | commitRemoveNotification(context, loadingNotification);
154 | commitAddNotification(context, { color: 'error', content: 'Error resetting password' });
155 | }
156 | },
157 | };
158 |
159 | const { dispatch } = getStoreAccessors('');
160 |
161 | export const dispatchCheckApiError = dispatch(actions.actionCheckApiError);
162 | export const dispatchCheckLoggedIn = dispatch(actions.actionCheckLoggedIn);
163 | export const dispatchGetUserProfile = dispatch(actions.actionGetUserProfile);
164 | export const dispatchLogIn = dispatch(actions.actionLogIn);
165 | export const dispatchLogOut = dispatch(actions.actionLogOut);
166 | export const dispatchUserLogOut = dispatch(actions.actionUserLogOut);
167 | export const dispatchRemoveLogIn = dispatch(actions.actionRemoveLogIn);
168 | export const dispatchRouteLoggedIn = dispatch(actions.actionRouteLoggedIn);
169 | export const dispatchRouteLogOut = dispatch(actions.actionRouteLogOut);
170 | export const dispatchUpdateUserProfile = dispatch(actions.actionUpdateUserProfile);
171 | export const dispatchRemoveNotification = dispatch(actions.removeNotification);
172 | export const dispatchPasswordRecovery = dispatch(actions.passwordRecovery);
173 | export const dispatchResetPassword = dispatch(actions.resetPassword);
174 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/store/main/getters.ts:
--------------------------------------------------------------------------------
1 | import { MainState } from './state';
2 | import { getStoreAccessors } from 'typesafe-vuex';
3 | import { State } from '../state';
4 |
5 | export const getters = {
6 | hasAdminAccess: (state: MainState) => {
7 | return (
8 | state.userProfile &&
9 | state.userProfile.is_superuser && state.userProfile.is_active);
10 | },
11 | loginError: (state: MainState) => state.logInError,
12 | dashboardShowDrawer: (state: MainState) => state.dashboardShowDrawer,
13 | dashboardMiniDrawer: (state: MainState) => state.dashboardMiniDrawer,
14 | userProfile: (state: MainState) => state.userProfile,
15 | token: (state: MainState) => state.token,
16 | isLoggedIn: (state: MainState) => state.isLoggedIn,
17 | firstNotification: (state: MainState) => state.notifications.length > 0 && state.notifications[0],
18 | };
19 |
20 | const {read} = getStoreAccessors('');
21 |
22 | export const readDashboardMiniDrawer = read(getters.dashboardMiniDrawer);
23 | export const readDashboardShowDrawer = read(getters.dashboardShowDrawer);
24 | export const readHasAdminAccess = read(getters.hasAdminAccess);
25 | export const readIsLoggedIn = read(getters.isLoggedIn);
26 | export const readLoginError = read(getters.loginError);
27 | export const readToken = read(getters.token);
28 | export const readUserProfile = read(getters.userProfile);
29 | export const readFirstNotification = read(getters.firstNotification);
30 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/store/main/index.ts:
--------------------------------------------------------------------------------
1 | import { mutations } from './mutations';
2 | import { getters } from './getters';
3 | import { actions } from './actions';
4 | import { MainState } from './state';
5 |
6 | const defaultState: MainState = {
7 | isLoggedIn: null,
8 | token: '',
9 | logInError: false,
10 | userProfile: null,
11 | dashboardMiniDrawer: false,
12 | dashboardShowDrawer: true,
13 | notifications: [],
14 | };
15 |
16 | export const mainModule = {
17 | state: defaultState,
18 | mutations,
19 | actions,
20 | getters,
21 | };
22 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/store/main/mutations.ts:
--------------------------------------------------------------------------------
1 | import { IUserProfile } from '@/interfaces';
2 | import { MainState, AppNotification } from './state';
3 | import { getStoreAccessors } from 'typesafe-vuex';
4 | import { State } from '../state';
5 |
6 |
7 | export const mutations = {
8 | setToken(state: MainState, payload: string) {
9 | state.token = payload;
10 | },
11 | setLoggedIn(state: MainState, payload: boolean) {
12 | state.isLoggedIn = payload;
13 | },
14 | setLogInError(state: MainState, payload: boolean) {
15 | state.logInError = payload;
16 | },
17 | setUserProfile(state: MainState, payload: IUserProfile) {
18 | state.userProfile = payload;
19 | },
20 | setDashboardMiniDrawer(state: MainState, payload: boolean) {
21 | state.dashboardMiniDrawer = payload;
22 | },
23 | setDashboardShowDrawer(state: MainState, payload: boolean) {
24 | state.dashboardShowDrawer = payload;
25 | },
26 | addNotification(state: MainState, payload: AppNotification) {
27 | state.notifications.push(payload);
28 | },
29 | removeNotification(state: MainState, payload: AppNotification) {
30 | state.notifications = state.notifications.filter((notification) => notification !== payload);
31 | },
32 | };
33 |
34 | const {commit} = getStoreAccessors('');
35 |
36 | export const commitSetDashboardMiniDrawer = commit(mutations.setDashboardMiniDrawer);
37 | export const commitSetDashboardShowDrawer = commit(mutations.setDashboardShowDrawer);
38 | export const commitSetLoggedIn = commit(mutations.setLoggedIn);
39 | export const commitSetLogInError = commit(mutations.setLogInError);
40 | export const commitSetToken = commit(mutations.setToken);
41 | export const commitSetUserProfile = commit(mutations.setUserProfile);
42 | export const commitAddNotification = commit(mutations.addNotification);
43 | export const commitRemoveNotification = commit(mutations.removeNotification);
44 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/store/main/state.ts:
--------------------------------------------------------------------------------
1 | import { IUserProfile } from '@/interfaces';
2 |
3 | export interface AppNotification {
4 | content: string;
5 | color?: string;
6 | showProgress?: boolean;
7 | }
8 |
9 | export interface MainState {
10 | token: string;
11 | isLoggedIn: boolean | null;
12 | logInError: boolean;
13 | userProfile: IUserProfile | null;
14 | dashboardMiniDrawer: boolean;
15 | dashboardShowDrawer: boolean;
16 | notifications: AppNotification[];
17 | }
18 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/store/state.ts:
--------------------------------------------------------------------------------
1 | import { MainState } from './main/state';
2 |
3 | export interface State {
4 | main: MainState;
5 | }
6 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/utils.ts:
--------------------------------------------------------------------------------
1 | export const getLocalToken = () => localStorage.getItem('token');
2 |
3 | export const saveLocalToken = (token: string) => localStorage.setItem('token', token);
4 |
5 | export const removeLocalToken = () => localStorage.removeItem('token');
6 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/views/Login.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {{appName}}
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | Incorrect email or password
19 |
20 |
21 | Forgot your password?
22 |
23 |
24 |
25 | Login
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
56 |
57 |
59 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/views/PasswordRecovery.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {{appName}} - Password Recovery
9 |
10 |
11 | A password recovery email will be sent to the registered account
12 |
13 |
14 |
15 |
16 |
17 |
18 | Cancel
19 |
20 | Recover Password
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
50 |
51 |
53 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/views/ResetPassword.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {{appName}} - Reset Password
9 |
10 |
11 | Enter your new password below
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | Cancel
22 | Clear
23 | Save
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
85 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/views/main/Dashboard.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Dashboard
6 |
7 |
8 | Welcome {{greetedUser}}
9 |
10 |
11 | View Profile
12 | Edit Profile
13 | Change Password
14 |
15 |
16 |
17 |
18 |
19 |
38 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/views/main/Main.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Main menu
7 |
8 |
9 | web
10 |
11 |
12 | Dashboard
13 |
14 |
15 |
16 |
17 | person
18 |
19 |
20 | Profile
21 |
22 |
23 |
24 |
25 | edit
26 |
27 |
28 | Edit Profile
29 |
30 |
31 |
32 |
33 | vpn_key
34 |
35 |
36 | Change Password
37 |
38 |
39 |
40 |
41 |
42 | Admin
43 |
44 |
45 | group
46 |
47 |
48 | Manage Users
49 |
50 |
51 |
52 |
53 | person_add
54 |
55 |
56 | Create User
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | close
65 |
66 |
67 | Logout
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 | Collapse
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 | more_vert
89 |
90 |
91 |
92 |
93 | Profile
94 |
95 |
96 | person
97 |
98 |
99 |
100 |
101 | Logout
102 |
103 |
104 | close
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 | © {{appName}}
116 |
117 |
118 |
119 |
120 |
183 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/views/main/Start.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
39 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/views/main/admin/Admin.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
29 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/views/main/admin/AdminUsers.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Manage Users
6 |
7 |
8 | Create User
9 |
10 |
11 |
12 | {{ props.item.name }} |
13 | {{ props.item.email }} |
14 | {{ props.item.full_name }} |
15 | checkmark |
16 | checkmark |
17 |
18 |
19 | Edit
20 |
21 | edit
22 |
23 |
24 | |
25 |
26 |
27 |
28 |
29 |
30 |
84 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/views/main/admin/CreateUser.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Create User
6 |
7 |
8 |
9 |
10 |
11 |
12 | User is superuser (currently is a superuser)(currently is not a superuser)
13 |
14 | User is active (currently active)(currently not active)
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | Cancel
30 | Reset
31 |
32 | Save
33 |
34 |
35 |
36 |
37 |
38 |
39 |
98 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/views/main/admin/EditUser.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Edit User
6 |
7 |
8 |
9 |
10 |
Username
11 |
{{user.email}}
15 |
-----
19 |
20 |
25 |
30 |
39 | User is superuser (currently is a superuser)(currently is not a superuser)
40 |
44 | User is active (currently active)(currently not active)
45 |
49 |
50 |
51 |
55 |
56 |
57 |
68 |
69 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 | Cancel
89 | Reset
90 |
94 | Save
95 |
96 |
97 |
98 |
99 |
100 |
101 |
164 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/views/main/profile/UserProfile.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | User Profile
6 |
7 |
8 |
9 |
Full Name
10 |
{{userProfile.full_name}}
11 |
-----
12 |
13 |
14 |
Email
15 |
{{userProfile.email}}
16 |
-----
17 |
18 |
19 |
20 | Edit
21 | Change password
22 |
23 |
24 |
25 |
26 |
27 |
47 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/views/main/profile/UserProfileEdit.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Edit User Profile
6 |
7 |
8 |
9 |
14 |
19 |
28 |
29 |
30 |
31 |
32 |
33 | Cancel
34 | Reset
35 |
39 | Save
40 |
41 |
42 |
43 |
44 |
45 |
46 |
98 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/src/views/main/profile/UserProfileEditPassword.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Set Password
6 |
7 |
8 |
9 |
10 |
User
11 |
{{userProfile.full_name}}
12 |
{{userProfile.email}}
13 |
14 |
15 |
25 |
26 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | Cancel
43 | Reset
44 | Save
45 |
46 |
47 |
48 |
49 |
50 |
87 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/tests/unit/upload-button.spec.ts:
--------------------------------------------------------------------------------
1 | import { shallowMount } from '@vue/test-utils';
2 | import UploadButton from '@/components/UploadButton.vue';
3 | import '@/plugins/vuetify';
4 |
5 | describe('UploadButton.vue', () => {
6 | it('renders props.title when passed', () => {
7 | const title = 'upload a file';
8 | const wrapper = shallowMount(UploadButton, {
9 | slots: {
10 | default: title,
11 | },
12 | });
13 | expect(wrapper.text()).toMatch(title);
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "noImplicitAny": false,
4 | "target": "esnext",
5 | "module": "esnext",
6 | "strict": true,
7 | "jsx": "preserve",
8 | "importHelpers": true,
9 | "moduleResolution": "node",
10 | "experimentalDecorators": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "sourceMap": true,
14 | "baseUrl": ".",
15 | "types": [
16 | "webpack-env",
17 | "jest"
18 | ],
19 | "paths": {
20 | "@/*": [
21 | "src/*"
22 | ]
23 | },
24 | "lib": [
25 | "esnext",
26 | "dom",
27 | "dom.iterable",
28 | "scripthost"
29 | ]
30 | },
31 | "include": [
32 | "src/**/*.ts",
33 | "src/**/*.tsx",
34 | "src/**/*.vue",
35 | "tests/**/*.ts",
36 | "tests/**/*.tsx"
37 | ],
38 | "exclude": [
39 | "node_modules"
40 | ]
41 | }
42 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "defaultSeverity": "warning",
3 | "extends": [
4 | "tslint:recommended"
5 | ],
6 | "linterOptions": {
7 | "exclude": [
8 | "node_modules/**"
9 | ]
10 | },
11 | "rules": {
12 | "quotemark": [true, "single"],
13 | "indent": [true, "spaces", 2],
14 | "interface-name": false,
15 | "ordered-imports": false,
16 | "object-literal-sort-keys": false,
17 | "no-consecutive-blank-lines": false
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/{{cookiecutter.project_slug}}/frontend/vue.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | // Fix Vuex-typescript in prod: https://github.com/istrib/vuex-typescript/issues/13#issuecomment-409869231
3 | configureWebpack: (config) => {
4 | if (process.env.NODE_ENV === 'production') {
5 | config.optimization.minimizer[0].options.terserOptions = Object.assign(
6 | {},
7 | config.optimization.minimizer[0].options.terserOptions,
8 | {
9 | ecma: 5,
10 | compress: {
11 | keep_fnames: true,
12 | },
13 | warnings: false,
14 | mangle: {
15 | keep_fnames: true,
16 | },
17 | },
18 | );
19 | }
20 | },
21 | chainWebpack: config => {
22 | config.module
23 | .rule('vue')
24 | .use('vue-loader')
25 | .loader('vue-loader')
26 | .tap(options => Object.assign(options, {
27 | transformAssetUrls: {
28 | 'v-img': ['src', 'lazy-src'],
29 | 'v-card': 'src',
30 | 'v-card-media': 'src',
31 | 'v-responsive': 'src',
32 | }
33 | }));
34 | },
35 | }
36 |
--------------------------------------------------------------------------------
/{{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 | INSTALL_DEV=true \
10 | docker-compose \
11 | -f docker-compose.yml \
12 | config > docker-stack.yml
13 |
14 | docker-compose -f docker-stack.yml build
15 | docker-compose -f docker-stack.yml down -v --remove-orphans # Remove possibly previous broken stacks left hanging after an error
16 | docker-compose -f docker-stack.yml up -d
17 | docker-compose -f docker-stack.yml exec -T backend bash /app/tests-start.sh "$@"
18 | docker-compose -f docker-stack.yml down -v --remove-orphans
19 |
--------------------------------------------------------------------------------