├── .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 }}
Go to Dashboard

-------------------------------------------------------------------------------- /{{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:
Reset Password
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 | 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 | 9 | 78 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/components/RouterComponent.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/components/UploadButton.vue: -------------------------------------------------------------------------------- 1 | 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 | 33 | 34 | 56 | 57 | 59 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/PasswordRecovery.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 50 | 51 | 53 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/ResetPassword.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 85 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/main/Dashboard.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 38 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/main/Main.vue: -------------------------------------------------------------------------------- 1 | 119 | 120 | 183 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/main/Start.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 39 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/main/admin/Admin.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 29 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/main/admin/AdminUsers.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 84 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/main/admin/CreateUser.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 98 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/main/admin/EditUser.vue: -------------------------------------------------------------------------------- 1 | 100 | 101 | 164 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/main/profile/UserProfile.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 47 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/main/profile/UserProfileEdit.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 98 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/main/profile/UserProfileEditPassword.vue: -------------------------------------------------------------------------------- 1 | 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 | --------------------------------------------------------------------------------