├── .github ├── FUNDING.yml └── workflows │ └── issue-manager.yml ├── {{cookiecutter.project_slug}} ├── backend │ ├── app │ │ ├── app │ │ │ ├── __init__.py │ │ │ ├── api │ │ │ │ ├── __init__.py │ │ │ │ ├── api_v1 │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── endpoints │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── utils.py │ │ │ │ │ │ ├── items.py │ │ │ │ │ │ ├── login.py │ │ │ │ │ │ └── users.py │ │ │ │ │ └── api.py │ │ │ │ └── deps.py │ │ │ ├── core │ │ │ │ ├── __init__.py │ │ │ │ ├── celery_app.py │ │ │ │ ├── security.py │ │ │ │ └── config.py │ │ │ ├── db │ │ │ │ ├── __init__.py │ │ │ │ ├── base.py │ │ │ │ ├── session.py │ │ │ │ ├── base_class.py │ │ │ │ └── init_db.py │ │ │ ├── tests │ │ │ │ ├── __init__.py │ │ │ │ ├── api │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── api_v1 │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── test_login.py │ │ │ │ │ │ ├── test_celery.py │ │ │ │ │ │ ├── test_items.py │ │ │ │ │ │ └── test_users.py │ │ │ │ ├── crud │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── test_item.py │ │ │ │ │ └── test_user.py │ │ │ │ ├── .gitignore │ │ │ │ ├── utils │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── utils.py │ │ │ │ │ └── user.py │ │ │ │ └── conftest.py │ │ │ ├── models │ │ │ │ ├── __init__.py │ │ │ │ ├── item.py │ │ │ │ └── user.py │ │ │ ├── schemas │ │ │ │ ├── msg.py │ │ │ │ ├── __init__.py │ │ │ │ ├── token.py │ │ │ │ ├── item.py │ │ │ │ └── user.py │ │ │ ├── worker.py │ │ │ ├── crud │ │ │ │ ├── __init__.py │ │ │ │ ├── crud_item.py │ │ │ │ ├── crud_user.py │ │ │ │ └── base.py │ │ │ ├── email-templates │ │ │ │ ├── src │ │ │ │ │ ├── test_email.mjml │ │ │ │ │ ├── new_account.mjml │ │ │ │ │ └── reset_password.mjml │ │ │ │ └── build │ │ │ │ │ ├── test_email.html │ │ │ │ │ ├── new_account.html │ │ │ │ │ └── reset_password.html │ │ │ ├── initial_data.py │ │ │ ├── main.py │ │ │ ├── backend_pre_start.py │ │ │ ├── tests_pre_start.py │ │ │ ├── celeryworker_pre_start.py │ │ │ └── utils.py │ │ ├── alembic │ │ │ ├── versions │ │ │ │ ├── .keep │ │ │ │ └── d4867f3a4c0a_first_revision.py │ │ │ ├── README │ │ │ ├── script.py.mako │ │ │ └── env.py │ │ ├── .gitignore │ │ ├── .flake8 │ │ ├── scripts │ │ │ ├── test-cov-html.sh │ │ │ ├── lint.sh │ │ │ ├── test.sh │ │ │ ├── format.sh │ │ │ └── format-imports.sh │ │ ├── tests-start.sh │ │ ├── mypy.ini │ │ ├── worker-start.sh │ │ ├── prestart.sh │ │ ├── pyproject.toml │ │ └── alembic.ini │ ├── .gitignore │ ├── start-reload.sh │ ├── celeryworker.dockerfile │ ├── start.sh │ ├── backend.dockerfile │ └── gunicorn_conf.py ├── frontend │ ├── .dockerignore │ ├── public │ │ ├── robots.txt │ │ ├── favicon.ico │ │ ├── img │ │ │ └── icons │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── mstile-150x150.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon-60x60.png │ │ │ │ ├── apple-touch-icon-76x76.png │ │ │ │ ├── apple-touch-icon-120x120.png │ │ │ │ ├── apple-touch-icon-152x152.png │ │ │ │ ├── apple-touch-icon-180x180.png │ │ │ │ └── msapplication-icon-144x144.png │ │ ├── manifest.json │ │ └── index.html │ ├── src │ │ ├── shims-vue.d.ts │ │ ├── store │ │ │ ├── state.ts │ │ │ ├── admin │ │ │ │ ├── state.ts │ │ │ │ ├── index.ts │ │ │ │ ├── getters.ts │ │ │ │ ├── mutations.ts │ │ │ │ └── actions.ts │ │ │ ├── main │ │ │ │ ├── state.ts │ │ │ │ ├── index.ts │ │ │ │ ├── getters.ts │ │ │ │ └── mutations.ts │ │ │ └── index.ts │ │ ├── shims-vuetify.d.ts │ │ ├── assets │ │ │ └── logo.png │ │ ├── plugins │ │ │ └── vuetify.ts │ │ ├── utils.ts │ │ ├── component-hooks.ts │ │ ├── components │ │ │ ├── RouterComponent.vue │ │ │ ├── UploadButton.vue │ │ │ └── NotificationsManager.vue │ │ ├── shims-tsx.d.ts │ │ ├── env.ts │ │ ├── main.ts │ │ ├── interfaces │ │ │ └── index.ts │ │ ├── views │ │ │ ├── main │ │ │ │ ├── admin │ │ │ │ │ ├── Admin.vue │ │ │ │ │ ├── AdminUsers.vue │ │ │ │ │ └── CreateUser.vue │ │ │ │ ├── Start.vue │ │ │ │ ├── Dashboard.vue │ │ │ │ ├── profile │ │ │ │ │ ├── UserProfile.vue │ │ │ │ │ ├── UserProfileEdit.vue │ │ │ │ │ └── UserProfileEditPassword.vue │ │ │ │ └── Main.vue │ │ │ ├── Login.vue │ │ │ ├── PasswordRecovery.vue │ │ │ └── ResetPassword.vue │ │ ├── registerServiceWorker.ts │ │ ├── App.vue │ │ ├── api.ts │ │ └── router.ts │ ├── tests │ │ └── unit │ │ │ ├── setup.js │ │ │ ├── utils.ts │ │ │ ├── upload-button.spec.ts │ │ │ └── views │ │ │ └── Login.spec.ts │ ├── nginx-backend-not-found.conf │ ├── babel.config.js │ ├── .prettierrc.js │ ├── nginx.conf │ ├── .gitignore │ ├── .env │ ├── README.md │ ├── tslint.json │ ├── vue.config.js │ ├── Dockerfile │ ├── tsconfig.json │ ├── .eslintrc.js │ └── package.json ├── .gitignore ├── scripts │ ├── build.sh │ ├── build-push.sh │ ├── test-local.sh │ ├── deploy.sh │ └── test.sh ├── .env ├── .gitlab-ci.yml ├── cookiecutter-config-file.yml └── docker-compose.override.yml ├── .gitignore ├── img ├── docs.png ├── login.png ├── redoc.png └── dashboard.png ├── .travis.yml ├── hooks └── post_gen_project.py ├── scripts ├── test.sh ├── dev-fsfp.sh ├── discard-dev-files.sh ├── generate_cookiecutter_config.py ├── dev-fsfp-back.sh └── dev-link.sh ├── LICENSE ├── cookiecutter.json └── CONTRIBUTING.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [tiangolo] 2 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/db/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/alembic/versions/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/crud/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | app.egg-info 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | testing-project 3 | .mypy_cache 4 | poetry.lock 5 | dev-link/ 6 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .mypy_cache 3 | docker-stack.yml 4 | -------------------------------------------------------------------------------- /img/docs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyb4430/full-stack-fastapi-postgresql/HEAD/img/docs.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache 2 | .coverage 3 | htmlcov 4 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /img/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyb4430/full-stack-fastapi-postgresql/HEAD/img/login.png -------------------------------------------------------------------------------- /img/redoc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyb4430/full-stack-fastapi-postgresql/HEAD/img/redoc.png -------------------------------------------------------------------------------- /img/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyb4430/full-stack-fastapi-postgresql/HEAD/img/dashboard.png -------------------------------------------------------------------------------- /{{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/schemas/msg.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class Msg(BaseModel): 5 | msg: str 6 | -------------------------------------------------------------------------------- /{{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}}/backend/app/.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | exclude = .git,__pycache__,__init__.py,.mypy_cache,.pytest_cache 4 | -------------------------------------------------------------------------------- /{{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}}/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/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/scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -x 4 | 5 | mypy app 6 | black app --check 7 | isort --check-only app 8 | flake8 9 | -------------------------------------------------------------------------------- /{{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}}/frontend/src/shims-vuetify.d.ts: -------------------------------------------------------------------------------- 1 | declare module "vuetify/lib/framework" { 2 | import Vuetify from "vuetify"; 3 | export default Vuetify; 4 | } 5 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | plugins = pydantic.mypy, sqlalchemy.ext.mypy.plugin 3 | ignore_missing_imports = True 4 | disallow_untyped_defs = True 5 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyb4430/full-stack-fastapi-postgresql/HEAD/{{cookiecutter.project_slug}}/frontend/public/favicon.ico -------------------------------------------------------------------------------- /{{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/tests/unit/setup.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import Vuetify from "vuetify"; 3 | 4 | Vue.use(Vuetify); 5 | Vue.config.productionTip = false; 6 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyb4430/full-stack-fastapi-postgresql/HEAD/{{cookiecutter.project_slug}}/frontend/src/assets/logo.png -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /{{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 -A app.worker worker -l info -Q main-queue -c 1 7 | -------------------------------------------------------------------------------- /{{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/public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyb4430/full-stack-fastapi-postgresql/HEAD/{{cookiecutter.project_slug}}/frontend/public/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyb4430/full-stack-fastapi-postgresql/HEAD/{{cookiecutter.project_slug}}/frontend/public/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/img/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyb4430/full-stack-fastapi-postgresql/HEAD/{{cookiecutter.project_slug}}/frontend/public/img/icons/mstile-150x150.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyb4430/full-stack-fastapi-postgresql/HEAD/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /{{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 app 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyb4430/full-stack-fastapi-postgresql/HEAD/{{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/pyb4430/full-stack-fastapi-postgresql/HEAD/{{cookiecutter.project_slug}}/frontend/public/img/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyb4430/full-stack-fastapi-postgresql/HEAD/{{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/pyb4430/full-stack-fastapi-postgresql/HEAD/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /{{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 --force-single-line-imports app 6 | sh ./scripts/format.sh 7 | -------------------------------------------------------------------------------- /{{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 | }; 11 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyb4430/full-stack-fastapi-postgresql/HEAD/{{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/pyb4430/full-stack-fastapi-postgresql/HEAD/{{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/pyb4430/full-stack-fastapi-postgresql/HEAD/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /{{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}}/frontend/public/img/icons/msapplication-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyb4430/full-stack-fastapi-postgresql/HEAD/{{cookiecutter.project_slug}}/frontend/public/img/icons/msapplication-icon-144x144.png -------------------------------------------------------------------------------- /{{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}}/frontend/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 88, 3 | tabWidth: 2, 4 | tabs: false, 5 | semi: true, 6 | singleQuote: false, 7 | trailingComma: "all", 8 | arrowParens: "always", 9 | }; 10 | -------------------------------------------------------------------------------- /{{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}}/frontend/src/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import Vuetify from "vuetify/lib/framework"; 3 | 4 | Vue.use(Vuetify); 5 | 6 | export default new Vuetify({ 7 | icons: { 8 | iconfont: "mdi", 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /{{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/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}}/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}}/frontend/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | 4 | location / { 5 | root /usr/share/nginx/html/; 6 | index index.html index.htm; 7 | try_files $uri $uri/ /index.html =404; 8 | } 9 | 10 | include /etc/nginx/extra-conf.d/*.conf; 11 | } -------------------------------------------------------------------------------- /{{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/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}}/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}}/frontend/src/components/RouterComponent.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 11 | -------------------------------------------------------------------------------- /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}}/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(str(settings.SQLALCHEMY_DATABASE_URI), pool_pre_ping=True) 7 | SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 8 | -------------------------------------------------------------------------------- /{{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}}/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}}/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /{{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 | 11 | # Generate __tablename__ automatically 12 | @declared_attr 13 | def __tablename__(cls) -> str: 14 | return cls.__name__.lower() 15 | -------------------------------------------------------------------------------- /{{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/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/.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}}/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/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}}/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/tests/unit/utils.ts: -------------------------------------------------------------------------------- 1 | import { Wrapper, WrapperArray } from "@vue/test-utils"; 2 | 3 | export function flushPromises() { 4 | return new Promise(function (resolve) { 5 | setTimeout(resolve); 6 | }); 7 | } 8 | 9 | // Vue test utils component selection 10 | 11 | export function componentWithText( 12 | wrapperArray: WrapperArray, 13 | text: string, 14 | ): Wrapper { 15 | return wrapperArray.filter((c) => c.text().includes(text)).at(0); 16 | } 17 | -------------------------------------------------------------------------------- /{{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/src/main.ts: -------------------------------------------------------------------------------- 1 | // Import Component hooks before component definitions 2 | import "./component-hooks"; 3 | import Vue from "vue"; 4 | import vuetify from "./plugins/vuetify"; 5 | import App from "./App.vue"; 6 | import router from "./router"; 7 | import store from "@/store"; 8 | import "./registerServiceWorker"; 9 | 10 | Vue.config.productionTip = false; 11 | 12 | new Vue({ 13 | router, 14 | store, 15 | vuetify, 16 | render: (h) => h(App), 17 | }).$mount("#app"); 18 | -------------------------------------------------------------------------------- /{{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/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}}/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/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}}/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}}/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}}/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/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/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/vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | chainWebpack: (config) => { 3 | config.module 4 | .rule("vue") 5 | .use("vue-loader") 6 | .loader("vue-loader") 7 | .tap((options) => 8 | Object.assign(options, { 9 | transformAssetUrls: { 10 | "v-img": ["src", "lazy-src"], 11 | "v-card": "src", 12 | "v-card-media": "src", 13 | "v-responsive": "src", 14 | }, 15 | }), 16 | ); 17 | }, 18 | 19 | transpileDependencies: ["vuetify"], 20 | }; 21 | -------------------------------------------------------------------------------- /{{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}}/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: int = 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: "User" = relationship("User", back_populates="items") 18 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /{{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}}/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /{{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}}/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/scripts/lint.sh 18 | docker-compose -f docker-stack.yml exec -T backend bash /app/tests-start.sh "$@" 19 | docker-compose -f docker-stack.yml down -v --remove-orphans 20 | -------------------------------------------------------------------------------- /{{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}}/frontend/src/views/main/admin/Admin.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 29 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 0, "build-stage", based on Node.js, to build and compile the frontend 2 | FROM node:18 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.22 24 | 25 | COPY --from=build-stage /app/dist/ /usr/share/nginx/html 26 | 27 | COPY ./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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/models/user.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, List 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: int = Column(Integer, primary_key=True, index=True) 14 | full_name = Column(String, index=True) 15 | email: str = Column(String, unique=True, index=True, nullable=False) 16 | hashed_password: str = Column(String, nullable=False) 17 | is_active: bool = Column(Boolean(), default=True) 18 | is_superuser: bool = Column(Boolean(), default=False) 19 | items: List["Item"] = relationship("Item", back_populates="owner") 20 | -------------------------------------------------------------------------------- /{{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}}/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/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/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}}/frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= VUE_APP_NAME %> 9 | 10 | 11 | 12 | 13 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /{{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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /{{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 | "vuetify" 19 | ], 20 | "paths": { 21 | "@/*": [ 22 | "src/*" 23 | ] 24 | }, 25 | "lib": [ 26 | "esnext", 27 | "dom", 28 | "dom.iterable", 29 | "scripthost" 30 | ] 31 | }, 32 | "include": [ 33 | "src/**/*.ts", 34 | "src/**/*.tsx", 35 | "src/**/*.vue", 36 | "tests/**/*.ts", 37 | "tests/**/*.tsx" 38 | ], 39 | "exclude": [ 40 | "node_modules" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /{{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/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", 25 | headers=superuser_token_headers, 26 | ) 27 | result = r.json() 28 | assert r.status_code == 200 29 | assert "email" in result 30 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_celery.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Dict 3 | 4 | import kombu 5 | from fastapi.testclient import TestClient 6 | 7 | from app.core.config import settings 8 | 9 | 10 | def test_celery_worker_test( 11 | client: TestClient, superuser_token_headers: Dict[str, str] 12 | ) -> None: 13 | data = {"msg": "test"} 14 | retries = 1 15 | r = None 16 | while (retries + 1) > 0: 17 | try: 18 | r = client.post( 19 | f"{settings.API_V1_STR}/utils/test-celery/", 20 | json=data, 21 | headers=superuser_token_headers, 22 | ) 23 | except kombu.exceptions.OperationalError: 24 | # This can happen right when the stack starts, 25 | # give celery a couple seconds 26 | time.sleep(5) 27 | retries -= 1 28 | continue 29 | break 30 | 31 | assert r 32 | response = r.json() 33 | assert response["msg"] == "Word received" 34 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/components/UploadButton.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 31 | 32 | 41 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/backend_pre_start.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from sqlalchemy import text 4 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed 5 | 6 | from app.db.session import SessionLocal 7 | 8 | logging.basicConfig(level=logging.INFO) 9 | logger = logging.getLogger(__name__) 10 | 11 | max_tries = 60 * 5 # 5 minutes 12 | wait_seconds = 1 13 | 14 | 15 | @retry( 16 | stop=stop_after_attempt(max_tries), 17 | wait=wait_fixed(wait_seconds), 18 | before=before_log(logger, logging.INFO), 19 | after=after_log(logger, logging.WARN), 20 | ) 21 | def init() -> None: 22 | try: 23 | db = SessionLocal() 24 | # Try to create session to check if DB is awake 25 | db.execute(text("SELECT 1")) 26 | except Exception as e: 27 | logger.error(e) 28 | raise e 29 | 30 | 31 | def main() -> None: 32 | logger.info("Initializing service") 33 | init() 34 | logger.info("Service finished initializing") 35 | 36 | 37 | if __name__ == "__main__": 38 | main() 39 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests_pre_start.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from sqlalchemy import text 4 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed 5 | 6 | from app.db.session import SessionLocal 7 | 8 | logging.basicConfig(level=logging.INFO) 9 | logger = logging.getLogger(__name__) 10 | 11 | max_tries = 60 * 5 # 5 minutes 12 | wait_seconds = 1 13 | 14 | 15 | @retry( 16 | stop=stop_after_attempt(max_tries), 17 | wait=wait_fixed(wait_seconds), 18 | before=before_log(logger, logging.INFO), 19 | after=after_log(logger, logging.WARN), 20 | ) 21 | def init() -> None: 22 | try: 23 | # Try to create session to check if DB is awake 24 | db = SessionLocal() 25 | db.execute(text("SELECT 1")) 26 | except Exception as e: 27 | logger.error(e) 28 | raise e 29 | 30 | 31 | def main() -> None: 32 | logger.info("Initializing service") 33 | init() 34 | logger.info("Service finished initializing") 35 | 36 | 37 | if __name__ == "__main__": 38 | main() 39 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/celeryworker_pre_start.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from sqlalchemy import text 4 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed 5 | 6 | from app.db.session import SessionLocal 7 | 8 | logging.basicConfig(level=logging.INFO) 9 | logger = logging.getLogger(__name__) 10 | 11 | max_tries = 60 * 5 # 5 minutes 12 | wait_seconds = 1 13 | 14 | 15 | @retry( 16 | stop=stop_after_attempt(max_tries), 17 | wait=wait_fixed(wait_seconds), 18 | before=before_log(logger, logging.INFO), 19 | after=after_log(logger, logging.WARN), 20 | ) 21 | def init() -> None: 22 | try: 23 | # Try to create session to check if DB is awake 24 | db = SessionLocal() 25 | db.execute(text("SELECT 1")) 26 | except Exception as e: 27 | logger.error(e) 28 | raise e 29 | 30 | 31 | def main() -> None: 32 | logger.info("Initializing service") 33 | init() 34 | logger.info("Service finished initializing") 35 | 36 | 37 | if __name__ == "__main__": 38 | main() 39 | -------------------------------------------------------------------------------- /{{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/start-reload.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | # From https://github.com/tiangolo/uvicorn-gunicorn-docker/blob/master/docker-images/start-reload.sh 3 | set -e 4 | 5 | if [ -f /app/app/main.py ]; then 6 | DEFAULT_MODULE_NAME=app.main 7 | elif [ -f /app/main.py ]; then 8 | DEFAULT_MODULE_NAME=main 9 | fi 10 | MODULE_NAME=${MODULE_NAME:-$DEFAULT_MODULE_NAME} 11 | VARIABLE_NAME=${VARIABLE_NAME:-app} 12 | export APP_MODULE=${APP_MODULE:-"$MODULE_NAME:$VARIABLE_NAME"} 13 | 14 | HOST=${HOST:-0.0.0.0} 15 | PORT=${PORT:-80} 16 | LOG_LEVEL=${LOG_LEVEL:-info} 17 | 18 | # If there's a prestart.sh script in the /app directory or other path specified, run it before starting 19 | PRE_START_PATH=${PRE_START_PATH:-/app/prestart.sh} 20 | echo "Checking for script in $PRE_START_PATH" 21 | if [ -f $PRE_START_PATH ] ; then 22 | echo "Running script $PRE_START_PATH" 23 | . "$PRE_START_PATH" 24 | else 25 | echo "There is no script $PRE_START_PATH" 26 | fi 27 | 28 | # Start Uvicorn with live reload 29 | exec uvicorn --reload --host $HOST --port $PORT --log-level $LOG_LEVEL "$APP_MODULE" -------------------------------------------------------------------------------- /{{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/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/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/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}}/frontend/src/views/main/Start.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 39 | -------------------------------------------------------------------------------- /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.project_slug}}/backend/celeryworker.dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10 2 | 3 | WORKDIR /app/ 4 | 5 | # Install Poetry 6 | RUN curl -sSL https://install.python-poetry.org | POETRY_HOME=/opt/poetry python3 7 | ENV PATH="/opt/poetry/bin:$PATH" 8 | RUN poetry config virtualenvs.create false 9 | 10 | # Copy poetry.lock* in case it doesn't exist in the repo 11 | COPY ./app/pyproject.toml ./app/poetry.lock* /app/ 12 | 13 | # Allow installing dev dependencies to run tests 14 | ARG INSTALL_DEV=false 15 | RUN bash -c "if [ $INSTALL_DEV == 'true' ] ; then poetry install --no-root ; else poetry install --no-root --no-dev ; fi" 16 | 17 | # For development, Jupyter remote kernel, Hydrogen 18 | # Using inside the container: 19 | # jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888 20 | ARG INSTALL_JUPYTER=false 21 | RUN bash -c "if [ $INSTALL_JUPYTER == 'true' ] ; then pip install jupyterlab ; fi" 22 | 23 | ENV C_FORCE_ROOT=1 24 | 25 | COPY ./app /app 26 | WORKDIR /app 27 | 28 | ENV PYTHONPATH=/app 29 | 30 | COPY ./app/worker-start.sh /worker-start.sh 31 | 32 | RUN chmod +x /worker-start.sh 33 | 34 | CMD ["bash", "/worker-start.sh"] 35 | -------------------------------------------------------------------------------- /{{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.10" 9 | uvicorn = "^0.18.3" 10 | fastapi = "^0.82.0" 11 | python-multipart = "^0.0.5" 12 | email-validator = "^1.2.1" 13 | requests = "^2.28.1" 14 | celery = "^5.2.7" 15 | passlib = {extras = ["bcrypt"], version = "^1.7.4"} 16 | tenacity = "^8.0.1" 17 | pydantic = "^1.10.2" 18 | emails = "^0.6" 19 | raven = "^6.10.0" 20 | gunicorn = "^20.1.0" 21 | jinja2 = "^3.1.2" 22 | psycopg2-binary = "^2.9.3" 23 | alembic = "^1.8.1" 24 | sqlalchemy = "^1.4.40" 25 | pytest = "^7.1.3" 26 | python-jose = {extras = ["cryptography"], version = "^3.3.0"} 27 | 28 | [tool.poetry.dev-dependencies] 29 | mypy = "^0.971" 30 | black = "^22.8.0" 31 | isort = "^5.10.1" 32 | autoflake = "^1.5.3" 33 | flake8 = "^5.0.4" 34 | pytest = "^7.1.3" 35 | sqlalchemy2-stubs = "^0.0.2a27" 36 | pytest-cov = "^3.0.0" 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}}/frontend/src/views/main/Dashboard.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 38 | -------------------------------------------------------------------------------- /{{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/start.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | # From https://github.com/tiangolo/uvicorn-gunicorn-docker/blob/master/docker-images/start.sh 3 | set -e 4 | 5 | if [ -f /app/app/main.py ]; then 6 | DEFAULT_MODULE_NAME=app.main 7 | elif [ -f /app/main.py ]; then 8 | DEFAULT_MODULE_NAME=main 9 | fi 10 | MODULE_NAME=${MODULE_NAME:-$DEFAULT_MODULE_NAME} 11 | VARIABLE_NAME=${VARIABLE_NAME:-app} 12 | export APP_MODULE=${APP_MODULE:-"$MODULE_NAME:$VARIABLE_NAME"} 13 | 14 | if [ -f /app/gunicorn_conf.py ]; then 15 | DEFAULT_GUNICORN_CONF=/app/gunicorn_conf.py 16 | elif [ -f /app/app/gunicorn_conf.py ]; then 17 | DEFAULT_GUNICORN_CONF=/app/app/gunicorn_conf.py 18 | else 19 | DEFAULT_GUNICORN_CONF=/gunicorn_conf.py 20 | fi 21 | export GUNICORN_CONF=${GUNICORN_CONF:-$DEFAULT_GUNICORN_CONF} 22 | export WORKER_CLASS=${WORKER_CLASS:-"uvicorn.workers.UvicornWorker"} 23 | 24 | # If there's a prestart.sh script in the /app directory or other path specified, run it before starting 25 | PRE_START_PATH=${PRE_START_PATH:-/app/prestart.sh} 26 | echo "Checking for script in $PRE_START_PATH" 27 | if [ -f $PRE_START_PATH ] ; then 28 | echo "Running script $PRE_START_PATH" 29 | . "$PRE_START_PATH" 30 | else 31 | echo "There is no script $PRE_START_PATH" 32 | fi 33 | 34 | # Start Gunicorn 35 | exec gunicorn -k "$WORKER_CLASS" -c "$GUNICORN_CONF" "$APP_MODULE" -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | const OFF = 0, 2 | ERROR = 2; 3 | 4 | module.exports = { 5 | root: true, 6 | env: { 7 | node: true, 8 | }, 9 | ignorePatterns: [ 10 | "!.eslintrc.js", 11 | "!.prettierrc.js", 12 | "node_modules/", 13 | "shims-tsx.d.ts", 14 | "shims-vue.d.ts", 15 | ], 16 | extends: [ 17 | "eslint:recommended", 18 | "plugin:@typescript-eslint/recommended", 19 | "plugin:vue/recommended", 20 | "@vue/eslint-config-typescript/recommended", 21 | "plugin:prettier/recommended", 22 | ], 23 | parserOptions: { 24 | ecmaVersion: 2020, 25 | }, 26 | rules: { 27 | "no-console": 28 | process.env.NODE_ENV === "production" ? [ERROR, { allow: ["error"] }] : OFF, 29 | "no-debugger": process.env.NODE_ENV === "production" ? ERROR : OFF, 30 | "@typescript-eslint/explicit-module-boundary-types": OFF, 31 | "@typescript-eslint/no-unused-vars": [ 32 | ERROR, 33 | { 34 | argsIgnorePattern: "^_", 35 | varsIgnorePattern: "^_", 36 | }, 37 | ], 38 | }, 39 | overrides: [ 40 | { 41 | files: ["**/__tests__/*.{j,t}s?(x)", "**/tests/unit/**/*.spec.{j,t}s?(x)"], 42 | env: { 43 | jest: true, 44 | }, 45 | }, 46 | { 47 | files: ["**/registerServiceWorker.ts"], 48 | rules: { 49 | "no-console": OFF, 50 | }, 51 | }, 52 | ], 53 | }; 54 | -------------------------------------------------------------------------------- /{{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/", 14 | headers=superuser_token_headers, 15 | json=data, 16 | ) 17 | assert response.status_code == 200 18 | content = response.json() 19 | assert content["title"] == data["title"] 20 | assert content["description"] == data["description"] 21 | assert "id" in content 22 | assert "owner_id" in content 23 | 24 | 25 | def test_read_item( 26 | client: TestClient, superuser_token_headers: dict, db: Session 27 | ) -> None: 28 | item = create_random_item(db) 29 | response = client.get( 30 | f"{settings.API_V1_STR}/items/{item.id}", 31 | headers=superuser_token_headers, 32 | ) 33 | assert response.status_code == 200 34 | content = response.json() 35 | assert content["title"] == item.title 36 | assert content["description"] == item.description 37 | assert content["id"] == item.id 38 | assert content["owner_id"] == item.owner_id 39 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 47 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/backend.dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10 2 | 3 | # Incorporates former base image setup from https://github.com/tiangolo/uvicorn-gunicorn-docker 4 | 5 | RUN pip install --no-cache-dir uvicorn[standard]==0.18.3 6 | RUN pip install --no-cache-dir gunicorn==20.1.0 7 | 8 | EXPOSE 80 9 | 10 | # Install Poetry 11 | RUN curl -sSL https://install.python-poetry.org | POETRY_HOME=/opt/poetry python3 12 | ENV PATH="/opt/poetry/bin:$PATH" 13 | RUN poetry config virtualenvs.create false 14 | 15 | # Copy poetry.lock* in case it doesn't exist in the repo 16 | COPY ./app/pyproject.toml ./app/poetry.lock* /app/ 17 | 18 | WORKDIR /app/ 19 | 20 | # Allow installing dev dependencies to run tests 21 | ARG INSTALL_DEV=false 22 | RUN bash -c "if [ $INSTALL_DEV == 'true' ] ; then poetry install --no-root ; else poetry install --no-root --no-dev ; fi" 23 | 24 | # For development, Jupyter remote kernel, Hydrogen 25 | # Using inside the container: 26 | # jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888 27 | ARG INSTALL_JUPYTER=false 28 | RUN bash -c "if [ $INSTALL_JUPYTER == 'true' ] ; then pip install jupyterlab ; fi" 29 | 30 | COPY ./start.sh /start.sh 31 | RUN chmod +x /start.sh 32 | 33 | COPY ./gunicorn_conf.py /gunicorn_conf.py 34 | 35 | COPY ./start-reload.sh /start-reload.sh 36 | RUN chmod +x /start-reload.sh 37 | 38 | COPY ./app /app 39 | ENV PYTHONPATH=/app 40 | 41 | CMD ["/start.sh"] -------------------------------------------------------------------------------- /{{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 && state.userProfile.is_superuser && state.userProfile.is_active 9 | ); 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) => 18 | state.notifications.length > 0 && state.notifications[0], 19 | }; 20 | 21 | const { read } = getStoreAccessors(""); 22 | 23 | export const readDashboardMiniDrawer = read(getters.dashboardMiniDrawer); 24 | export const readDashboardShowDrawer = read(getters.dashboardShowDrawer); 25 | export const readHasAdminAccess = read(getters.hasAdminAccess); 26 | export const readIsLoggedIn = read(getters.isLoggedIn); 27 | export const readLoginError = read(getters.loginError); 28 | export const readToken = read(getters.token); 29 | export const readUserProfile = read(getters.userProfile); 30 | export const readFirstNotification = read(getters.firstNotification); 31 | -------------------------------------------------------------------------------- /{{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}}/.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}}/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( 26 | `${apiUrl}/api/v1/users/me`, 27 | data, 28 | authHeaders(token), 29 | ); 30 | }, 31 | async getUsers(token: string) { 32 | return axios.get(`${apiUrl}/api/v1/users/`, authHeaders(token)); 33 | }, 34 | async updateUser(token: string, userId: number, data: IUserProfileUpdate) { 35 | return axios.put(`${apiUrl}/api/v1/users/${userId}`, data, authHeaders(token)); 36 | }, 37 | async createUser(token: string, data: IUserProfileCreate) { 38 | return axios.post(`${apiUrl}/api/v1/users/`, data, authHeaders(token)); 39 | }, 40 | async passwordRecovery(email: string) { 41 | return axios.post(`${apiUrl}/api/v1/password-recovery/${email}`); 42 | }, 43 | async resetPassword(password: string, token: string) { 44 | return axios.post(`${apiUrl}/api/v1/reset-password/`, { 45 | new_password: password, 46 | token, 47 | }); 48 | }, 49 | }; 50 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/tests/unit/views/Login.spec.ts: -------------------------------------------------------------------------------- 1 | import { createLocalVue, mount } from "@vue/test-utils"; 2 | import Login from "@/views/Login.vue"; 3 | import "@/plugins/vuetify"; 4 | import VueRouter from "vue-router"; 5 | import Vuetify from "vuetify"; 6 | import { componentWithText } from "../utils"; 7 | 8 | import Vuex, { ActionTree, GetterTree, Store } from "vuex"; 9 | 10 | const localVue = createLocalVue(); 11 | 12 | localVue.use(VueRouter); 13 | const router = new VueRouter(); 14 | 15 | localVue.use(Vuex); 16 | 17 | describe("Login.vue", () => { 18 | /* eslint-disable @typescript-eslint/no-explicit-any */ 19 | let getters: GetterTree; 20 | let actions: ActionTree; 21 | let store: Store; 22 | /* eslint-enable @typescript-eslint/no-explicit-any */ 23 | let vuetify; 24 | 25 | beforeEach(() => { 26 | vuetify = new Vuetify(); 27 | getters = {}; 28 | 29 | actions = { 30 | actionLogIn: jest.fn(), 31 | }; 32 | 33 | store = new Vuex.Store({ 34 | getters, 35 | actions, 36 | }); 37 | }); 38 | 39 | it("calls the login action", async () => { 40 | const wrapper = mount(Login, { store, localVue, router, vuetify }); 41 | const textFields = wrapper.findAllComponents({ name: "v-text-field" }); 42 | const emailField = componentWithText(textFields, "Login"); 43 | emailField.get("input").setValue("yo@da.com"); 44 | const passwordField = componentWithText(textFields, "Password"); 45 | passwordField.get("input").setValue("secretpass"); 46 | 47 | const loginBtn = componentWithText( 48 | wrapper.findAllComponents({ name: "v-btn" }), 49 | "Login", 50 | ); 51 | loginBtn.trigger("click"); 52 | 53 | expect(actions.actionLogIn).toHaveBeenCalledWith(expect.anything(), { 54 | username: "yo@da.com", 55 | password: "secretpass", 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /{{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}}/frontend/src/views/main/profile/UserProfile.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 56 | -------------------------------------------------------------------------------- /{{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}}/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}}/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 | export const mutations = { 7 | setToken(state: MainState, payload: string) { 8 | state.token = payload; 9 | }, 10 | setLoggedIn(state: MainState, payload: boolean) { 11 | state.isLoggedIn = payload; 12 | }, 13 | setLogInError(state: MainState, payload: boolean) { 14 | state.logInError = payload; 15 | }, 16 | setUserProfile(state: MainState, payload: IUserProfile) { 17 | state.userProfile = payload; 18 | }, 19 | setDashboardMiniDrawer(state: MainState, payload: boolean) { 20 | state.dashboardMiniDrawer = payload; 21 | }, 22 | setDashboardShowDrawer(state: MainState, payload: boolean) { 23 | state.dashboardShowDrawer = payload; 24 | }, 25 | addNotification(state: MainState, payload: AppNotification) { 26 | state.notifications.push(payload); 27 | }, 28 | removeNotification(state: MainState, payload: AppNotification) { 29 | state.notifications = state.notifications.filter( 30 | (notification) => notification !== payload, 31 | ); 32 | }, 33 | }; 34 | 35 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 36 | const { commit } = getStoreAccessors(""); 37 | 38 | export const commitSetDashboardMiniDrawer = commit(mutations.setDashboardMiniDrawer); 39 | export const commitSetDashboardShowDrawer = commit(mutations.setDashboardShowDrawer); 40 | export const commitSetLoggedIn = commit(mutations.setLoggedIn); 41 | export const commitSetLogInError = commit(mutations.setLogInError); 42 | export const commitSetToken = commit(mutations.setToken); 43 | export const commitSetUserProfile = commit(mutations.setUserProfile); 44 | export const commitAddNotification = commit(mutations.addNotification); 45 | export const commitRemoveNotification = commit(mutations.removeNotification); 46 | -------------------------------------------------------------------------------- /{{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/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.get("password", None): 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.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 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/gunicorn_conf.py: -------------------------------------------------------------------------------- 1 | # From https://github.com/tiangolo/uvicorn-gunicorn-docker/blob/master/docker-images/gunicorn_conf.py 2 | import json 3 | import multiprocessing 4 | import os 5 | 6 | workers_per_core_str = os.getenv("WORKERS_PER_CORE", "1") 7 | max_workers_str = os.getenv("MAX_WORKERS") 8 | use_max_workers = None 9 | if max_workers_str: 10 | use_max_workers = int(max_workers_str) 11 | web_concurrency_str = os.getenv("WEB_CONCURRENCY", None) 12 | 13 | host = os.getenv("HOST", "0.0.0.0") 14 | port = os.getenv("PORT", "80") 15 | bind_env = os.getenv("BIND", None) 16 | use_loglevel = os.getenv("LOG_LEVEL", "info") 17 | if bind_env: 18 | use_bind = bind_env 19 | else: 20 | use_bind = f"{host}:{port}" 21 | 22 | cores = multiprocessing.cpu_count() 23 | workers_per_core = float(workers_per_core_str) 24 | default_web_concurrency = workers_per_core * cores 25 | if web_concurrency_str: 26 | web_concurrency = int(web_concurrency_str) 27 | assert web_concurrency > 0 28 | else: 29 | web_concurrency = max(int(default_web_concurrency), 2) 30 | if use_max_workers: 31 | web_concurrency = min(web_concurrency, use_max_workers) 32 | accesslog_var = os.getenv("ACCESS_LOG", "-") 33 | use_accesslog = accesslog_var or None 34 | errorlog_var = os.getenv("ERROR_LOG", "-") 35 | use_errorlog = errorlog_var or None 36 | graceful_timeout_str = os.getenv("GRACEFUL_TIMEOUT", "120") 37 | timeout_str = os.getenv("TIMEOUT", "120") 38 | keepalive_str = os.getenv("KEEP_ALIVE", "5") 39 | 40 | # Gunicorn config variables 41 | loglevel = use_loglevel 42 | workers = web_concurrency 43 | bind = use_bind 44 | errorlog = use_errorlog 45 | worker_tmp_dir = "/dev/shm" 46 | accesslog = use_accesslog 47 | graceful_timeout = int(graceful_timeout_str) 48 | timeout = int(timeout_str) 49 | keepalive = int(keepalive_str) 50 | 51 | 52 | # For debugging and testing 53 | log_data = { 54 | "loglevel": loglevel, 55 | "workers": workers, 56 | "bind": bind, 57 | "graceful_timeout": graceful_timeout, 58 | "timeout": timeout, 59 | "keepalive": keepalive, 60 | "errorlog": errorlog, 61 | "accesslog": accesslog, 62 | # Additional, non-gunicorn variables 63 | "workers_per_core": workers_per_core, 64 | "use_max_workers": use_max_workers, 65 | "host": host, 66 | "port": port, 67 | } 68 | print(json.dumps(log_data)) -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/main/admin/AdminUsers.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 84 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/crud/base.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union, cast 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.get(self.model, id) 64 | assert obj is not None 65 | db.delete(obj) 66 | db.commit() 67 | return cast(ModelType, obj) 68 | -------------------------------------------------------------------------------- /{{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/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}}/frontend/src/views/Login.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /{{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}}/frontend/src/components/NotificationsManager.vue: -------------------------------------------------------------------------------- 1 | 16 | 85 | -------------------------------------------------------------------------------- /{{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 | "axios": "0.27.2", 13 | "core-js": "3.25.0", 14 | "register-service-worker": "1.7.2", 15 | "typesafe-vuex": "3.2.2", 16 | "vee-validate": "3.4.14", 17 | "vue": "2.7.10", 18 | "vue-class-component": "7.2.6", 19 | "vue-property-decorator": "9.1.2", 20 | "vue-router": "3.6.5", 21 | "vuetify": "2.6.9", 22 | "vuex": "3.6.2" 23 | }, 24 | "devDependencies": { 25 | "@types/jest": "27.4.1", 26 | "@typescript-eslint/eslint-plugin": "5.36.2", 27 | "@typescript-eslint/parser": "5.36.2", 28 | "@vue/cli-plugin-babel": "5.0.8", 29 | "@vue/cli-plugin-eslint": "5.0.8", 30 | "@vue/cli-plugin-pwa": "5.0.8", 31 | "@vue/cli-plugin-router": "5.0.8", 32 | "@vue/cli-plugin-typescript": "5.0.8", 33 | "@vue/cli-plugin-unit-jest": "5.0.8", 34 | "@vue/cli-plugin-vuex": "5.0.8", 35 | "@vue/cli-service": "5.0.8", 36 | "@vue/eslint-config-typescript": "11.0.1", 37 | "@vue/test-utils": "1.3.0", 38 | "@vue/vue2-jest": "27.0.0", 39 | "eslint": "8.23.0", 40 | "eslint-config-prettier": "8.5.0", 41 | "eslint-plugin-prettier": "4.2.1", 42 | "eslint-plugin-vue": "9.4.0", 43 | "jest-environment-jsdom": "27.5.1", 44 | "prettier": "2.7.1", 45 | "sass": "~1.32.0", 46 | "sass-loader": "^10.0.0", 47 | "ts-jest": "27.1.5", 48 | "typescript": "4.8.2", 49 | "vue-cli-plugin-vuetify": "~2.5.5", 50 | "vue-template-compiler": "2.7.10", 51 | "vuetify-loader": "1.9.2" 52 | }, 53 | "postcss": { 54 | "plugins": { 55 | "autoprefixer": {} 56 | } 57 | }, 58 | "browserslist": [ 59 | "> 1%", 60 | "last 2 versions", 61 | "not ie <= 10" 62 | ], 63 | "jest": { 64 | "testEnvironment": "jsdom", 65 | "moduleFileExtensions": [ 66 | "js", 67 | "jsx", 68 | "json", 69 | "vue", 70 | "ts", 71 | "tsx" 72 | ], 73 | "transform": { 74 | "^.+\\.vue$": "@vue/vue2-jest", 75 | ".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$": "jest-transform-stub", 76 | "^.+\\.tsx?$": "ts-jest" 77 | }, 78 | "moduleNameMapper": { 79 | "^@/(.*)$": "/src/$1" 80 | }, 81 | "snapshotSerializers": [ 82 | "jest-serializer-vue" 83 | ], 84 | "setupFiles": [ 85 | "/tests/unit/setup.js" 86 | ], 87 | "preset": "@vue/cli-plugin-unit-jest/presets/typescript-and-babel", 88 | "transformIgnorePatterns": [ 89 | "/node_modules/(?!@mdi|vuetify|vee-validate/dist/rules)" 90 | ], 91 | "testMatch": [ 92 | "**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)" 93 | ], 94 | "testURL": "http://localhost/" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /{{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( 25 | context: MainContext, 26 | payload: { id: number; user: IUserProfileUpdate }, 27 | ) { 28 | try { 29 | const loadingNotification = { content: "saving", showProgress: true }; 30 | commitAddNotification(context, loadingNotification); 31 | const response = ( 32 | await Promise.all([ 33 | api.updateUser(context.rootState.main.token, payload.id, payload.user), 34 | await new Promise((resolve, _) => setTimeout(() => resolve(), 500)), 35 | ]) 36 | )[0]; 37 | commitSetUser(context, response.data); 38 | commitRemoveNotification(context, loadingNotification); 39 | commitAddNotification(context, { 40 | content: "User successfully updated", 41 | color: "success", 42 | }); 43 | } catch (error) { 44 | await dispatchCheckApiError(context, error); 45 | } 46 | }, 47 | async actionCreateUser(context: MainContext, payload: IUserProfileCreate) { 48 | try { 49 | const loadingNotification = { content: "saving", showProgress: true }; 50 | commitAddNotification(context, loadingNotification); 51 | const response = ( 52 | await Promise.all([ 53 | api.createUser(context.rootState.main.token, payload), 54 | await new Promise((resolve, _) => setTimeout(() => resolve(), 500)), 55 | ]) 56 | )[0]; 57 | commitSetUser(context, response.data); 58 | commitRemoveNotification(context, loadingNotification); 59 | commitAddNotification(context, { 60 | content: "User successfully created", 61 | color: "success", 62 | }); 63 | } catch (error) { 64 | await dispatchCheckApiError(context, error); 65 | } 66 | }, 67 | }; 68 | 69 | const { dispatch } = getStoreAccessors(""); 70 | 71 | export const dispatchCreateUser = dispatch(actions.actionCreateUser); 72 | export const dispatchGetUsers = dispatch(actions.actionGetUsers); 73 | export const dispatchUpdateUser = dispatch(actions.actionUpdateUser); 74 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/PasswordRecovery.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /{{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/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}}/docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | services: 2 | 3 | proxy: 4 | ports: 5 | - "80:80" 6 | - "8090:8080" 7 | command: 8 | # Enable Docker in Traefik, so that it reads labels from Docker services 9 | - --providers.docker 10 | # Add a constraint to only use services with the label for this stack 11 | # from the env var TRAEFIK_TAG 12 | - --providers.docker.constraints=Label(`traefik.constraint-label-stack`, `${TRAEFIK_TAG?Variable not set}`) 13 | # Do not expose all Docker services, only the ones explicitly exposed 14 | - --providers.docker.exposedbydefault=false 15 | # Disable Docker Swarm mode for local development 16 | # - --providers.docker.swarmmode 17 | # Enable the access log, with HTTP requests 18 | - --accesslog 19 | # Enable the Traefik log, for configurations and errors 20 | - --log 21 | # Enable the Dashboard and API 22 | - --api 23 | # Enable the Dashboard and API in insecure mode for local development 24 | - --api.insecure=true 25 | labels: 26 | - traefik.enable=true 27 | - traefik.http.routers.${STACK_NAME?Variable not set}-traefik-public-http.rule=Host(`${DOMAIN?Variable not set}`) 28 | - traefik.http.services.${STACK_NAME?Variable not set}-traefik-public.loadbalancer.server.port=80 29 | 30 | pgadmin: 31 | ports: 32 | - "5050:5050" 33 | 34 | flower: 35 | ports: 36 | - "5555:5555" 37 | 38 | backend: 39 | ports: 40 | - "8888:8888" 41 | volumes: 42 | - ./backend/app:/app 43 | environment: 44 | - JUPYTER=jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888 45 | - SERVER_HOST=http://${DOMAIN?Variable not set} 46 | build: 47 | context: ./backend 48 | dockerfile: backend.dockerfile 49 | args: 50 | INSTALL_DEV: ${INSTALL_DEV-true} 51 | INSTALL_JUPYTER: ${INSTALL_JUPYTER-true} 52 | # command: bash -c "while true; do sleep 1; done" # Infinite loop to keep container live doing nothing 53 | command: /start-reload.sh 54 | labels: 55 | - traefik.enable=true 56 | - traefik.constraint-label-stack=${TRAEFIK_TAG?Variable not set} 57 | - traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.rule=PathPrefix(`/api`) || PathPrefix(`/docs`) || PathPrefix(`/redoc`) 58 | - traefik.http.services.${STACK_NAME?Variable not set}-backend.loadbalancer.server.port=80 59 | 60 | celeryworker: 61 | volumes: 62 | - ./backend/app:/app 63 | environment: 64 | - RUN=celery -A app.worker worker -l info -Q main-queue -c 1 65 | - JUPYTER=jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888 66 | - SERVER_HOST=http://${DOMAIN?Variable not set} 67 | build: 68 | context: ./backend 69 | dockerfile: celeryworker.dockerfile 70 | args: 71 | INSTALL_DEV: ${INSTALL_DEV-true} 72 | INSTALL_JUPYTER: ${INSTALL_JUPYTER-true} 73 | 74 | frontend: 75 | build: 76 | context: ./frontend 77 | args: 78 | FRONTEND_ENV: dev 79 | labels: 80 | - traefik.enable=true 81 | - traefik.constraint-label-stack=${TRAEFIK_TAG?Variable not set} 82 | - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.rule=PathPrefix(`/`) 83 | - traefik.http.services.${STACK_NAME?Variable not set}-frontend.loadbalancer.server.port=80 84 | 85 | networks: 86 | traefik-public: 87 | # For local dev, don't expect an external Traefik network 88 | external: false 89 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/main/profile/UserProfileEdit.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 106 | -------------------------------------------------------------------------------- /{{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/email-templates/build/test_email.html: -------------------------------------------------------------------------------- 1 |

{{ project_name }}
Test email for: {{ email }}
-------------------------------------------------------------------------------- /{{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}, 97 | settings.SECRET_KEY, 98 | algorithm="HS256", 99 | ) 100 | return encoded_jwt 101 | 102 | 103 | def verify_password_reset_token(token: str) -> Optional[str]: 104 | try: 105 | decoded_token = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) 106 | return decoded_token["email"] 107 | except jwt.JWTError: 108 | return None 109 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/main/profile/UserProfileEditPassword.vue: -------------------------------------------------------------------------------- 1 | 64 | 65 | 118 | -------------------------------------------------------------------------------- /{{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: () => 26 | import( 27 | /* webpackChunkName: "recover-password" */ "./views/PasswordRecovery.vue" 28 | ), 29 | }, 30 | { 31 | path: "reset-password", 32 | component: () => 33 | import( 34 | /* webpackChunkName: "reset-password" */ "./views/ResetPassword.vue" 35 | ), 36 | }, 37 | { 38 | path: "main", 39 | component: () => 40 | import(/* webpackChunkName: "main" */ "./views/main/Main.vue"), 41 | children: [ 42 | { 43 | path: "dashboard", 44 | component: () => 45 | import( 46 | /* webpackChunkName: "main-dashboard" */ "./views/main/Dashboard.vue" 47 | ), 48 | }, 49 | { 50 | path: "profile", 51 | component: RouterComponent, 52 | redirect: "profile/view", 53 | children: [ 54 | { 55 | path: "view", 56 | component: () => 57 | import( 58 | /* webpackChunkName: "main-profile" */ "./views/main/profile/UserProfile.vue" 59 | ), 60 | }, 61 | { 62 | path: "edit", 63 | component: () => 64 | import( 65 | /* webpackChunkName: "main-profile-edit" */ "./views/main/profile/UserProfileEdit.vue" 66 | ), 67 | }, 68 | { 69 | path: "password", 70 | component: () => 71 | import( 72 | /* webpackChunkName: "main-profile-password" */ "./views/main/profile/UserProfileEditPassword.vue" 73 | ), 74 | }, 75 | ], 76 | }, 77 | { 78 | path: "admin", 79 | component: () => 80 | import( 81 | /* webpackChunkName: "main-admin" */ "./views/main/admin/Admin.vue" 82 | ), 83 | redirect: "admin/users/all", 84 | children: [ 85 | { 86 | path: "users", 87 | redirect: "users/all", 88 | }, 89 | { 90 | path: "users/all", 91 | component: () => 92 | import( 93 | /* webpackChunkName: "main-admin-users" */ "./views/main/admin/AdminUsers.vue" 94 | ), 95 | }, 96 | { 97 | path: "users/edit/:id", 98 | name: "main-admin-users-edit", 99 | component: () => 100 | import( 101 | /* webpackChunkName: "main-admin-users-edit" */ "./views/main/admin/EditUser.vue" 102 | ), 103 | }, 104 | { 105 | path: "users/create", 106 | name: "main-admin-users-create", 107 | component: () => 108 | import( 109 | /* webpackChunkName: "main-admin-users-create" */ "./views/main/admin/CreateUser.vue" 110 | ), 111 | }, 112 | ], 113 | }, 114 | ], 115 | }, 116 | ], 117 | }, 118 | { 119 | path: "/*", 120 | redirect: "/", 121 | }, 122 | ], 123 | }); 124 | -------------------------------------------------------------------------------- /{{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/", 42 | headers=superuser_token_headers, 43 | json=data, 44 | ) 45 | assert 200 <= r.status_code < 300 46 | created_user = r.json() 47 | user = crud.user.get_by_email(db, email=username) 48 | assert user 49 | assert user.email == created_user["email"] 50 | 51 | 52 | def test_get_existing_user( 53 | client: TestClient, superuser_token_headers: dict, db: Session 54 | ) -> None: 55 | username = random_email() 56 | password = random_lower_string() 57 | user_in = UserCreate(email=username, password=password) 58 | user = crud.user.create(db, obj_in=user_in) 59 | user_id = user.id 60 | r = client.get( 61 | f"{settings.API_V1_STR}/users/{user_id}", 62 | headers=superuser_token_headers, 63 | ) 64 | assert 200 <= r.status_code < 300 65 | api_user = r.json() 66 | existing_user = crud.user.get_by_email(db, email=username) 67 | assert existing_user 68 | assert existing_user.email == api_user["email"] 69 | 70 | 71 | def test_create_user_existing_username( 72 | client: TestClient, superuser_token_headers: dict, db: Session 73 | ) -> None: 74 | username = random_email() 75 | # username = email 76 | password = random_lower_string() 77 | user_in = UserCreate(email=username, password=password) 78 | crud.user.create(db, obj_in=user_in) 79 | data = {"email": username, "password": password} 80 | r = client.post( 81 | f"{settings.API_V1_STR}/users/", 82 | headers=superuser_token_headers, 83 | json=data, 84 | ) 85 | created_user = r.json() 86 | assert r.status_code == 400 87 | assert "_id" not in created_user 88 | 89 | 90 | def test_create_user_by_normal_user( 91 | client: TestClient, normal_user_token_headers: Dict[str, str] 92 | ) -> None: 93 | username = random_email() 94 | password = random_lower_string() 95 | data = {"email": username, "password": password} 96 | r = client.post( 97 | f"{settings.API_V1_STR}/users/", 98 | headers=normal_user_token_headers, 99 | json=data, 100 | ) 101 | assert r.status_code == 400 102 | 103 | 104 | def test_retrieve_users( 105 | client: TestClient, superuser_token_headers: dict, db: Session 106 | ) -> None: 107 | username = random_email() 108 | password = random_lower_string() 109 | user_in = UserCreate(email=username, password=password) 110 | crud.user.create(db, obj_in=user_in) 111 | 112 | username2 = random_email() 113 | password2 = random_lower_string() 114 | user_in2 = UserCreate(email=username2, password=password2) 115 | crud.user.create(db, obj_in=user_in2) 116 | 117 | r = client.get(f"{settings.API_V1_STR}/users/", headers=superuser_token_headers) 118 | all_users = r.json() 119 | 120 | assert len(all_users) > 1 121 | for item in all_users: 122 | assert "email" in item 123 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/ResetPassword.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 129 | -------------------------------------------------------------------------------- /{{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/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}}/frontend/src/views/main/admin/CreateUser.vue: -------------------------------------------------------------------------------- 1 | 86 | 87 | 161 | -------------------------------------------------------------------------------- /{{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}}/frontend/src/views/main/Main.vue: -------------------------------------------------------------------------------- 1 | 129 | 130 | 194 | --------------------------------------------------------------------------------