├── .github ├── FUNDING.yml └── workflows │ └── issue-manager.yml ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── cookiecutter.json ├── docs ├── authentication-guide.md ├── deployment-guide.md ├── development-guide.md ├── getting-started.md └── websocket-guide.md ├── hooks └── post_gen_project.py ├── img ├── dashboard.png ├── docs.png ├── landing.png ├── login.png ├── redoc.png └── totp.png ├── scripts ├── dev-fsfp-back.sh ├── dev-fsfp.sh ├── dev-link.sh ├── discard-dev-files.sh ├── generate_cookiecutter_config.py └── test.sh └── {{cookiecutter.project_slug}} ├── .dockerignore ├── .env ├── .gitattributes ├── .gitignore ├── .gitlab-ci.yml ├── README.md ├── backend ├── .dockerignore ├── .gitignore ├── app │ ├── .flake8 │ ├── .gitignore │ ├── .python-version │ ├── README.md │ ├── alembic.ini │ ├── alembic │ │ ├── README │ │ ├── env.py │ │ ├── script.py.mako │ │ └── versions │ │ │ ├── .keep │ │ │ ├── 8188d671489a_deeper_authentication.py │ │ │ ├── c4f38069dc24_first_revision.py │ │ │ └── fb120f8fc198_token_remove_to_invalidate.py │ ├── app │ │ ├── __init__.py │ │ ├── __version__.py │ │ ├── api │ │ │ ├── __init__.py │ │ │ ├── api_v1 │ │ │ │ ├── __init__.py │ │ │ │ ├── api.py │ │ │ │ └── endpoints │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── login.py │ │ │ │ │ ├── proxy.py │ │ │ │ │ ├── services.py │ │ │ │ │ └── users.py │ │ │ ├── deps.py │ │ │ └── sockets.py │ │ ├── backend_pre_start.py │ │ ├── celeryworker_pre_start.py │ │ ├── core │ │ │ ├── __init__.py │ │ │ ├── celery_app.py │ │ │ ├── config.py │ │ │ └── security.py │ │ ├── crud │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── crud_token.py │ │ │ ├── crud_user.py │ │ │ └── neo_base.py │ │ ├── db │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── base_class.py │ │ │ ├── init_db.py │ │ │ └── session.py │ │ ├── email-templates │ │ │ ├── build │ │ │ │ ├── confirm_email.html │ │ │ │ ├── magic_login.html │ │ │ │ ├── new_account.html │ │ │ │ ├── reset_password.html │ │ │ │ ├── test_email.html │ │ │ │ └── web_contact_email.html │ │ │ └── src │ │ │ │ ├── confirm_email.mjml │ │ │ │ ├── magic_login.mjml │ │ │ │ ├── new_account.mjml │ │ │ │ ├── reset_password.mjml │ │ │ │ ├── test_email.mjml │ │ │ │ └── web_contact_email.mjml │ │ ├── gdb │ │ │ ├── __init__.py │ │ │ ├── base_edge_class.py │ │ │ ├── base_node_class.py │ │ │ ├── init_gdb.py │ │ │ └── neomodel_config.py │ │ ├── initial_data.py │ │ ├── main.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── token.py │ │ │ └── user.py │ │ ├── schema_types │ │ │ ├── __init__.py │ │ │ └── base_type.py │ │ ├── schemas │ │ │ ├── __init__.py │ │ │ ├── base_schema.py │ │ │ ├── emails.py │ │ │ ├── msg.py │ │ │ ├── token.py │ │ │ ├── totp.py │ │ │ └── user.py │ │ ├── tests │ │ │ ├── .gitignore │ │ │ ├── __init__.py │ │ │ ├── api │ │ │ │ ├── __init__.py │ │ │ │ └── api_v1 │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── test_celery.py │ │ │ │ │ ├── test_items.py │ │ │ │ │ ├── test_login.py │ │ │ │ │ └── test_users.py │ │ │ ├── conftest.py │ │ │ ├── crud │ │ │ │ ├── __init__.py │ │ │ │ ├── test_item.py │ │ │ │ └── test_user.py │ │ │ └── utils │ │ │ │ ├── __init__.py │ │ │ │ ├── item.py │ │ │ │ ├── user.py │ │ │ │ └── utils.py │ │ ├── tests_pre_start.py │ │ ├── utilities │ │ │ ├── __init__.py │ │ │ └── email.py │ │ └── worker │ │ │ ├── __init__.py │ │ │ └── tests.py │ ├── mypy.ini │ ├── prestart.sh │ ├── pyproject.toml │ ├── scripts │ │ ├── format-imports.sh │ │ ├── format.sh │ │ ├── lint.sh │ │ ├── test-cov-html.sh │ │ └── test.sh │ ├── tests-start.sh │ └── worker-start.sh ├── backend.dockerfile └── celeryworker.dockerfile ├── cookiecutter-config-file.yml ├── docker-compose.override.yml ├── docker-compose.yml ├── frontend ├── .dockerignore ├── .editorconfig ├── .env ├── .gitattributes ├── .gitignore ├── .prettierrc ├── Dockerfile ├── README.md ├── api │ ├── auth.ts │ ├── core.ts │ ├── index.ts │ └── services.ts ├── app.vue ├── assets │ └── css │ │ └── main.css ├── components │ ├── alerts │ │ └── Button.vue │ ├── authentication │ │ ├── MagicLoginCard.vue │ │ └── Navigation.vue │ ├── layouts │ │ ├── Notification.vue │ │ ├── default │ │ │ ├── Footer.vue │ │ │ └── Navigation.vue │ │ └── home │ │ │ └── Navigation.vue │ ├── locale │ │ ├── LocaleDropdown.vue │ │ └── LocaleLink.vue │ ├── moderation │ │ ├── CheckState.vue │ │ ├── CheckToggle.vue │ │ ├── CreateUser.vue │ │ ├── ToggleActive.vue │ │ ├── ToggleMod.vue │ │ └── UserTable.vue │ ├── pwa │ │ ├── PwaBadge.client.vue │ │ ├── PwaInstallPrompt.client.vue │ │ └── PwaPrompt.client.vue │ └── settings │ │ ├── Profile.vue │ │ ├── Security.vue │ │ └── ValidateEmailButton.vue ├── config │ └── i18n.ts ├── content │ ├── about.md │ ├── authentication.md │ ├── blog │ │ ├── 20160708-theranos-and-elitism.md │ │ ├── 20160721-lament-for-the-auther.md │ │ └── 20170203-summer-of-99.md │ ├── fr │ │ └── about.md │ └── privacy.md ├── interfaces │ ├── index.ts │ ├── profile.ts │ └── utilities.ts ├── layouts │ ├── authentication.vue │ ├── content.vue │ ├── default.vue │ └── home.vue ├── locales │ ├── en-GB.ts │ └── fr-FR.ts ├── middleware │ ├── anonymous.ts │ ├── authenticated.ts │ ├── moderator.ts │ └── refresh.ts ├── nuxt.config.ts ├── package.json ├── pages │ ├── [...slug].vue │ ├── blog │ │ ├── [...slug].vue │ │ └── index.vue │ ├── contact.vue │ ├── index.vue │ ├── login.vue │ ├── magic.vue │ ├── moderation.vue │ ├── recover-password.vue │ ├── reset-password.vue │ ├── settings.vue │ └── totp.vue ├── plugins │ ├── veevalidate-components.ts │ └── veevalidate-rules.ts ├── public │ └── images │ │ ├── apple-touch-icon-180x180.png │ │ ├── favicon.ico │ │ ├── logo.svg │ │ ├── mark.svg │ │ ├── maskable-icon-512x512.png │ │ ├── pwa-192x192.png │ │ ├── pwa-512x512.png │ │ └── pwa-64x64.png ├── stores │ ├── auth.ts │ ├── index.ts │ ├── toasts.ts │ └── tokens.ts ├── tailwind.config.js ├── tsconfig.json ├── utilities │ ├── generic.ts │ ├── index.ts │ ├── textual.ts │ └── totp.ts └── yarn.lock └── scripts ├── build-push.sh ├── build.sh ├── deploy.sh ├── test-local.sh └── test.sh /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [tiangolo] 2 | -------------------------------------------------------------------------------- /.github/workflows/issue-manager.yml: -------------------------------------------------------------------------------- 1 | name: Issue Manager 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | issue_comment: 7 | types: 8 | - created 9 | - edited 10 | issues: 11 | types: 12 | - labeled 13 | 14 | jobs: 15 | issue-manager: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: tiangolo/issue-manager@0.2.0 19 | with: 20 | token: ${{ secrets.GITHUB_TOKEN }} 21 | config: > 22 | { 23 | "answered": { 24 | "users": ["tiangolo"], 25 | "delay": 864000, 26 | "message": "Assuming the original issue was solved, it will be automatically closed now. But feel free to add more comments or create new issues." 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | testing-project 3 | .mypy_cache 4 | # poetry.lock 5 | dev-link/ 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | 3 | language: python 4 | 5 | install: 6 | - pip install cookiecutter 7 | 8 | services: 9 | - docker 10 | 11 | script: 12 | - bash ./scripts/test.sh 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Here are some short guidelines to guide you if you want to contribute to the development of the Full Stack FastAPI PostgreSQL project generator itself. 4 | 5 | After you clone the project, there are several scripts that can help during development. 6 | 7 | * `./scripts/dev-fsfp.sh`: 8 | 9 | Generate a new default project `dev-fsfp`. 10 | 11 | Call it from one level above the project directory. So, if the project is at `~/code/full-stack-fastapi-postgresql/`, call it from `~/code/`, like: 12 | 13 | ```console 14 | $ cd ~/code/ 15 | 16 | $ bash ./full-stack-fastapi-postgresql/scripts/dev-fsfp.sh 17 | ``` 18 | 19 | It will generate a new project with all the defaults at `~/code/dev-fsfp/`. 20 | 21 | You can go to that directory with a full new project, edit files and test things, for example: 22 | 23 | ```console 24 | $ cd ./dev-fsfp/ 25 | 26 | $ docker-compose up -d 27 | ``` 28 | 29 | It is outside of the project generator directory to let you add Git to it and compare versions and changes. 30 | 31 | * `./scripts/dev-fsfp-back.sh`: 32 | 33 | Move the changes from a project `dev-fsfp` back to the project generator. 34 | 35 | You would call it after calling `./scripts/dev-fsfp.sh` and adding some modifications to `dev-fsfp`. 36 | 37 | Call it from one level above the project directory. So, if the project is at `~/code/full-stack-fastapi-postgresql/`, call it from `~/code/`, like: 38 | 39 | ```console 40 | $ cd ~/code/ 41 | 42 | $ bash ./full-stack-fastapi-postgresql/scripts/dev-fsfp-back.sh 43 | ``` 44 | 45 | That will also contain all the generated files with the generated variables, but it will let you compare the changes in `dev-fsfp` and the source in the project generator with git, and see what to commit. 46 | 47 | * `./scripts/discard-dev-files.sh`: 48 | 49 | After using `./scripts/dev-fsfp-back.sh`, there will be a bunch of generated files with the variables for the generated project that you don't want to commit, like `README.md` and `.gitlab-ci.yml`. 50 | 51 | To discard all those changes at once, run `discard-dev-files.sh` from the root of the project, e.g.: 52 | 53 | ```console 54 | $ cd ~/code/full-stack-fastapi-postgresql/ 55 | 56 | $ bash ./scripts/dev-fsfp-back.sh 57 | ``` 58 | 59 | * `./scripts/test.sh`: 60 | 61 | Run the tests. It creates a project `testing-project` *inside* of the project generator and runs its tests. 62 | 63 | Call it from the root of the project, e.g.: 64 | 65 | ```console 66 | $ cd ~/code/full-stack-fastapi-postgresql/ 67 | 68 | $ bash ./scripts/test.sh 69 | ``` 70 | 71 | * `./scripts/dev-link.sh`: 72 | 73 | Set up a local directory with links to the files for live development with the source files. 74 | 75 | This script generates a project `dev-link` *inside* the project generator, just to generate the `.env` and `./frontend/.env` files. 76 | 77 | Then it removes everything except those 2 files. 78 | 79 | Then it creates links for each of the source files, and adds those 2 files back. 80 | 81 | The end result is that you can go into the `dev-link` directory and develop locally with it as if it was a generated project, with all the variables set. But all the changes are actually done directly in the source files. 82 | 83 | This is probably a lot faster to iterate than using `./scripts/dev-fsfp.sh`. But it's tested only in Linux, it might not work in other systems. 84 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Sebastián Ramírez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_name": "Base Project", 3 | "project_slug": "{{ cookiecutter.project_name|lower|replace(' ', '-') }}", 4 | "domain_main": "{{cookiecutter.project_slug}}.com", 5 | "domain_staging": "stag.{{cookiecutter.domain_main}}", 6 | "domain_base_api_url": "http://{{cookiecutter.domain_main}}/api/v1", 7 | "domain_base_ws_url": "ws://{{cookiecutter.domain_main}}/api/v1", 8 | 9 | "docker_swarm_stack_name_main": "{{cookiecutter.domain_main|replace('.', '-')}}", 10 | "docker_swarm_stack_name_staging": "{{cookiecutter.domain_staging|replace('.', '-')}}", 11 | 12 | "secret_key": "changethis", 13 | "totp_secret_key": "changethis", 14 | "first_superuser": "admin@{{cookiecutter.domain_main}}", 15 | "first_superuser_password": "changethis", 16 | "backend_cors_origins": "[\"http://localhost\", \"http://localhost:4200\", \"http://localhost:3000\", \"http://localhost:8080\", \"https://localhost\", \"https://localhost:4200\", \"https://localhost:3000\", \"https://localhost:8080\", \"http://dev.{{cookiecutter.domain_main}}\", \"https://{{cookiecutter.domain_staging}}\", \"https://{{cookiecutter.domain_main}}\", \"http://local.dockertoolbox.tiangolo.com\", \"http://localhost.tiangolo.com\"]", 17 | "smtp_tls": true, 18 | "smtp_port": "587", 19 | "smtp_host": "", 20 | "smtp_user": "", 21 | "smtp_password": "", 22 | "smtp_emails_from_email": "info@{{cookiecutter.domain_main}}", 23 | "smtp_emails_from_name": "Symona Adaro", 24 | "smtp_emails_to_email": "info@{{cookiecutter.domain_main}}", 25 | 26 | "postgres_password": "changethis", 27 | "pgadmin_default_user": "{{cookiecutter.first_superuser}}", 28 | "pgadmin_default_user_password": "{{cookiecutter.first_superuser_password}}", 29 | 30 | "neo4j_password": "changethis", 31 | 32 | "traefik_constraint_tag": "{{cookiecutter.domain_main}}", 33 | "traefik_constraint_tag_staging": "{{cookiecutter.domain_staging}}", 34 | "traefik_public_constraint_tag": "traefik-public", 35 | 36 | "flower_auth": "admin:{{cookiecutter.first_superuser_password}}", 37 | 38 | "sentry_dsn": "", 39 | 40 | "docker_image_prefix": "", 41 | 42 | "docker_image_backend": "{{cookiecutter.docker_image_prefix}}backend", 43 | "docker_image_celeryworker": "{{cookiecutter.docker_image_prefix}}celeryworker", 44 | "docker_image_frontend": "{{cookiecutter.docker_image_prefix}}frontend", 45 | 46 | "_copy_without_render": [ 47 | "frontend/**/*.html", 48 | "frontend/**/*.vue", 49 | "frontend/.nuxt/*", 50 | "frontend/node_modules/*", 51 | "backend/app/app/email-templates/**", 52 | "backend/app/app/gdb/**" 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /hooks/post_gen_project.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | path: Path 5 | for path in Path(".").glob("**/*.sh"): 6 | data = path.read_bytes() 7 | lf_data = data.replace(b"\r\n", b"\n") 8 | path.write_bytes(lf_data) 9 | -------------------------------------------------------------------------------- /img/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/img/dashboard.png -------------------------------------------------------------------------------- /img/docs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/img/docs.png -------------------------------------------------------------------------------- /img/landing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/img/landing.png -------------------------------------------------------------------------------- /img/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/img/login.png -------------------------------------------------------------------------------- /img/redoc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/img/redoc.png -------------------------------------------------------------------------------- /img/totp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/img/totp.png -------------------------------------------------------------------------------- /scripts/dev-fsfp-back.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Run this script from outside the project, to integrate a dev-fsfp project with changes and review modifications 4 | 5 | # Exit in case of error 6 | set -e 7 | 8 | if [ ! -d ./full-stack-fastapi-postgresql ] ; then 9 | echo "Run this script from outside the project, to integrate a sibling dev-fsfp project with changes and review modifications" 10 | exit 1 11 | fi 12 | 13 | if [ $(uname -s) = "Linux" ]; then 14 | echo "Remove __pycache__ files" 15 | sudo find ./dev-fsfp/ -type d -name __pycache__ -exec rm -r {} \+ 16 | fi 17 | 18 | rm -rf ./full-stack-fastapi-postgresql/\{\{cookiecutter.project_slug\}\}/* 19 | 20 | rsync -a --exclude=node_modules ./dev-fsfp/* ./full-stack-fastapi-postgresql/\{\{cookiecutter.project_slug\}\}/ 21 | 22 | rsync -a ./dev-fsfp/{.env,.gitignore,.gitlab-ci.yml} ./full-stack-fastapi-postgresql/\{\{cookiecutter.project_slug\}\}/ 23 | -------------------------------------------------------------------------------- /scripts/dev-fsfp.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | if [ ! -d ./full-stack-fastapi-postgresql ] ; then 7 | echo "Run this script from outside the project, to generate a sibling dev-fsfp project with independent git" 8 | exit 1 9 | fi 10 | 11 | rm -rf ./dev-fsfp 12 | 13 | cookiecutter --no-input -f ./full-stack-fastapi-postgresql project_name="Dev FSFP" 14 | -------------------------------------------------------------------------------- /scripts/dev-link.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | # Run this from the root of the project to generate a dev-link project 7 | # It will contain a link to each of the files of the generator, except for 8 | # .env and frontend/.env, that will be the generated ones 9 | # This allows developing with a live stack while keeping the same source code 10 | # Without having to generate dev-fsfp and integrating back all the files 11 | 12 | rm -rf dev-link 13 | mkdir -p tmp-dev-link/frontend 14 | 15 | cookiecutter --no-input -f ./ project_name="Dev Link" 16 | 17 | mv ./dev-link/.env ./tmp-dev-link/ 18 | mv ./dev-link/frontend/.env ./tmp-dev-link/frontend/ 19 | 20 | rm -rf ./dev-link/ 21 | mkdir -p ./dev-link/ 22 | 23 | cd ./dev-link/ 24 | 25 | for f in ../\{\{cookiecutter.project_slug\}\}/* ; do 26 | ln -s "$f" ./ 27 | done 28 | 29 | cd .. 30 | 31 | mv ./tmp-dev-link/.env ./dev-link/ 32 | mv ./tmp-dev-link/frontend/.env ./dev-link/frontend/ 33 | 34 | rm -rf ./tmp-dev-link 35 | -------------------------------------------------------------------------------- /scripts/discard-dev-files.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | set -e 4 | 5 | rm -rf \{\{cookiecutter.project_slug\}\}/.git 6 | rm -rf \{\{cookiecutter.project_slug\}\}/backend/app/poetry.lock 7 | rm -rf \{\{cookiecutter.project_slug\}\}/frontend/node_modules 8 | rm -rf \{\{cookiecutter.project_slug\}\}/frontend/.nuxt 9 | git checkout \{\{cookiecutter.project_slug\}\}/README.md 10 | git checkout \{\{cookiecutter.project_slug\}\}/.gitlab-ci.yml 11 | git checkout \{\{cookiecutter.project_slug\}\}/cookiecutter-config-file.yml 12 | git checkout \{\{cookiecutter.project_slug\}\}/.env 13 | git checkout \{\{cookiecutter.project_slug\}\}/frontend/.env 14 | -------------------------------------------------------------------------------- /scripts/generate_cookiecutter_config.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections import OrderedDict 3 | import oyaml as yaml 4 | from pathlib import Path 5 | cookie_path = Path('./cookiecutter.json') 6 | out_path = Path('./{{cookiecutter.project_slug}}/cookiecutter-config-file.yml') 7 | 8 | with open(cookie_path) as f: 9 | cookie_config = json.load(f) 10 | config_out = OrderedDict() 11 | 12 | for key, value in cookie_config.items(): 13 | if key.startswith('_'): 14 | config_out[key] = value 15 | else: 16 | config_out[key] = '{{ cookiecutter.' + key + ' }}' 17 | config_out['_template'] = './' 18 | 19 | with open(out_path, 'w') as out_f: 20 | out_f.write(yaml.dump({'default_context': config_out}, line_break=None, width=200)) 21 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | # Run this from the root of the project 7 | 8 | rm -rf ./testing-project 9 | 10 | cookiecutter --no-input -f ./ project_name="Testing Project" 11 | 12 | cd ./testing-project 13 | 14 | bash ./scripts/test.sh "$@" 15 | 16 | cd ../ 17 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/.dockerignore: -------------------------------------------------------------------------------- 1 | # Get rid of .venv when copying 2 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file 3 | */.venv 4 | */*/.venv -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/.env: -------------------------------------------------------------------------------- 1 | DOMAIN=localhost 2 | # DOMAIN=local.dockertoolbox.tiangolo.com 3 | # DOMAIN=localhost.tiangolo.com 4 | # DOMAIN=dev.{{cookiecutter.domain_main}} 5 | 6 | STACK_NAME={{cookiecutter.docker_swarm_stack_name_main}} 7 | 8 | TRAEFIK_PUBLIC_NETWORK=traefik-public 9 | TRAEFIK_TAG={{cookiecutter.traefik_constraint_tag}} 10 | TRAEFIK_PUBLIC_TAG={{cookiecutter.traefik_public_constraint_tag}} 11 | 12 | DOCKER_IMAGE_BACKEND={{cookiecutter.docker_image_backend}} 13 | DOCKER_IMAGE_CELERYWORKER={{cookiecutter.docker_image_celeryworker}} 14 | DOCKER_IMAGE_FRONTEND={{cookiecutter.docker_image_frontend}} 15 | 16 | # Backend 17 | BACKEND_APP_MODULE=app.main:app 18 | BACKEND_CORS_ORIGINS={{cookiecutter.backend_cors_origins}} 19 | BACKEND_PRE_START_PATH=/app/prestart.sh 20 | PROJECT_NAME={{cookiecutter.project_name}} 21 | SECRET_KEY={{cookiecutter.secret_key}} 22 | TOTP_SECRET_KEY={{cookiecutter.totp_secret_key}} 23 | FIRST_SUPERUSER={{cookiecutter.first_superuser}} 24 | FIRST_SUPERUSER_PASSWORD={{cookiecutter.first_superuser_password}} 25 | SMTP_TLS={{cookiecutter.smtp_tls}} 26 | SMTP_PORT={{cookiecutter.smtp_port}} 27 | SMTP_HOST={{cookiecutter.smtp_host}} 28 | SMTP_USER={{cookiecutter.smtp_user}} 29 | SMTP_PASSWORD={{cookiecutter.smtp_password}} 30 | EMAILS_FROM_EMAIL={{cookiecutter.smtp_emails_from_email}} 31 | EMAILS_FROM_NAME={{cookiecutter.smtp_emails_from_name}} 32 | EMAILS_TO_EMAIL={{cookiecutter.smtp_emails_to_email}} 33 | 34 | USERS_OPEN_REGISTRATION=True 35 | 36 | SENTRY_DSN={{cookiecutter.sentry_dsn}} 37 | 38 | # Flower 39 | FLOWER_BASIC_AUTH={{cookiecutter.flower_auth}} 40 | 41 | # Postgres 42 | POSTGRES_SERVER=db 43 | POSTGRES_USER=postgres 44 | POSTGRES_PASSWORD={{cookiecutter.postgres_password}} 45 | POSTGRES_DB=app 46 | 47 | # PgAdmin 48 | PGADMIN_LISTEN_PORT=5050 49 | PGADMIN_DEFAULT_EMAIL={{cookiecutter.pgadmin_default_user}} 50 | PGADMIN_DEFAULT_PASSWORD={{cookiecutter.pgadmin_default_user_password}} 51 | 52 | # Neo4j 53 | NEO4J_SERVER=neo4j 54 | NEO4J_USERNAME=neo4j 55 | NEO4J_PASSWORD={{cookiecutter.neo4j_password}} 56 | NEO4J_AUTH=neo4j:{{cookiecutter.neo4j_password}} 57 | NEO4J_BOLT=bolt -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text eol=lf 3 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .mypy_cache 3 | docker-stack.yml 4 | *.txt 5 | .env 6 | *.code-workspace 7 | .s3cfg -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: tiangolo/docker-with-compose 2 | 3 | before_script: 4 | - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY 5 | - pip install docker-auto-labels 6 | 7 | stages: 8 | - test 9 | - build 10 | - deploy 11 | 12 | tests: 13 | stage: test 14 | script: 15 | - sh ./scripts/test.sh 16 | tags: 17 | - build 18 | - test 19 | 20 | build-stag: 21 | stage: build 22 | script: 23 | - TAG=stag FRONTEND_ENV=staging sh ./scripts/build-push.sh 24 | only: 25 | - master 26 | tags: 27 | - build 28 | - test 29 | 30 | build-prod: 31 | stage: build 32 | script: 33 | - TAG=prod FRONTEND_ENV=production sh ./scripts/build-push.sh 34 | only: 35 | - production 36 | tags: 37 | - build 38 | - test 39 | 40 | deploy-stag: 41 | stage: deploy 42 | script: 43 | - > 44 | DOMAIN={{cookiecutter.domain_staging}} 45 | TRAEFIK_TAG={{cookiecutter.traefik_constraint_tag_staging}} 46 | STACK_NAME={{cookiecutter.docker_swarm_stack_name_staging}} 47 | TAG=stag 48 | sh ./scripts/deploy.sh 49 | environment: 50 | name: staging 51 | url: https://{{cookiecutter.domain_staging}} 52 | only: 53 | - master 54 | tags: 55 | - swarm 56 | - stag 57 | 58 | deploy-prod: 59 | stage: deploy 60 | script: 61 | - > 62 | DOMAIN={{cookiecutter.domain_main}} 63 | TRAEFIK_TAG={{cookiecutter.traefik_constraint_tag}} 64 | STACK_NAME={{cookiecutter.docker_swarm_stack_name_main}} 65 | TAG=prod 66 | sh ./scripts/deploy.sh 67 | environment: 68 | name: production 69 | url: https://{{cookiecutter.domain_main}} 70 | only: 71 | - production 72 | tags: 73 | - swarm 74 | - prod 75 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/.dockerignore: -------------------------------------------------------------------------------- 1 | # Get rid of .venv when copying 2 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file 3 | */.venv 4 | */*/.venv -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | app.egg-info 3 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E302, E305, E203, E501, W503 3 | select = C,E,F,W,B,B950 4 | max-line-length = 120 5 | exclude = .git,__pycache__,__init__.py,.mypy_cache,.pytest_cache 6 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache 2 | .coverage 3 | htmlcov 4 | .venv 5 | .vscode 6 | *.py[co] 7 | *.egg 8 | *.egg-info 9 | *.ipynb 10 | *.code-workspace 11 | dist 12 | eggs 13 | parts 14 | bin 15 | var 16 | sdist 17 | develop-eggs 18 | .installed.cfg 19 | pip-log.txt 20 | .coverage 21 | .tox 22 | *.mo 23 | .s3cfg -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/.python-version: -------------------------------------------------------------------------------- 1 | 3.11.0 2 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | #truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to alembic/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat alembic/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | # Logging configuration 39 | [loggers] 40 | keys = root,sqlalchemy,alembic 41 | 42 | [handlers] 43 | keys = console 44 | 45 | [formatters] 46 | keys = generic 47 | 48 | [logger_root] 49 | level = WARN 50 | handlers = console 51 | qualname = 52 | 53 | [logger_sqlalchemy] 54 | level = WARN 55 | handlers = 56 | qualname = sqlalchemy.engine 57 | 58 | [logger_alembic] 59 | level = INFO 60 | handlers = 61 | qualname = alembic 62 | 63 | [handler_console] 64 | class = StreamHandler 65 | args = (sys.stderr,) 66 | level = NOTSET 67 | formatter = generic 68 | 69 | [formatter_generic] 70 | format = %(levelname)-5.5s [%(name)s] %(message)s 71 | datefmt = %H:%M:%S 72 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/alembic/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | import os 4 | 5 | from alembic import context 6 | from sqlalchemy import engine_from_config, pool 7 | from logging.config import fileConfig 8 | 9 | # this is the Alembic Config object, which provides 10 | # access to the values within the .ini file in use. 11 | config = context.config 12 | 13 | # Interpret the config file for Python logging. 14 | # This line sets up loggers basically. 15 | fileConfig(config.config_file_name) 16 | 17 | # add your model's MetaData object here 18 | # for 'autogenerate' support 19 | # from myapp import mymodel 20 | # target_metadata = mymodel.Base.metadata 21 | # target_metadata = None 22 | 23 | from app.db.base import Base # noqa 24 | 25 | target_metadata = Base.metadata 26 | 27 | # other values from the config, defined by the needs of env.py, 28 | # can be acquired: 29 | # my_important_option = config.get_main_option("my_important_option") 30 | # ... etc. 31 | 32 | 33 | def get_url(): 34 | user = os.getenv("POSTGRES_USER", "postgres") 35 | password = os.getenv("POSTGRES_PASSWORD", "") 36 | server = os.getenv("POSTGRES_SERVER", "db") 37 | db = os.getenv("POSTGRES_DB", "app") 38 | return f"postgresql+psycopg://{user}:{password}@{server}/{db}" 39 | 40 | 41 | def run_migrations_offline(): 42 | """Run migrations in 'offline' mode. 43 | 44 | This configures the context with just a URL 45 | and not an Engine, though an Engine is acceptable 46 | here as well. By skipping the Engine creation 47 | we don't even need a DBAPI to be available. 48 | 49 | Calls to context.execute() here emit the given string to the 50 | script output. 51 | 52 | """ 53 | url = get_url() 54 | context.configure( 55 | url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True 56 | ) 57 | 58 | with context.begin_transaction(): 59 | context.run_migrations() 60 | 61 | 62 | def run_migrations_online(): 63 | """Run migrations in 'online' mode. 64 | 65 | In this scenario we need to create an Engine 66 | and associate a connection with the context. 67 | 68 | """ 69 | configuration = config.get_section(config.config_ini_section) 70 | configuration["sqlalchemy.url"] = get_url() 71 | connectable = engine_from_config( 72 | configuration, prefix="sqlalchemy.", poolclass=pool.NullPool, 73 | ) 74 | 75 | with connectable.connect() as connection: 76 | context.configure( 77 | connection=connection, target_metadata=target_metadata, compare_type=True 78 | ) 79 | 80 | with context.begin_transaction(): 81 | context.run_migrations() 82 | 83 | 84 | if context.is_offline_mode(): 85 | run_migrations_offline() 86 | else: 87 | run_migrations_online() 88 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/alembic/versions/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/{{cookiecutter.project_slug}}/backend/app/alembic/versions/.keep -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/alembic/versions/8188d671489a_deeper_authentication.py: -------------------------------------------------------------------------------- 1 | """Deeper authentication 2 | 3 | Revision ID: 8188d671489a 4 | Revises: c4f38069dc24 5 | Create Date: 2023-01-01 15:31:40.986707 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '8188d671489a' 14 | down_revision = 'c4f38069dc24' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('user', sa.Column('created', sa.DateTime(), server_default=sa.text('now()'), nullable=False)) 22 | op.add_column('user', sa.Column('modified', sa.DateTime(), server_default=sa.text('now()'), nullable=False)) 23 | op.add_column('user', sa.Column('totp_secret', sa.String(), nullable=True)) 24 | op.add_column('user', sa.Column('totp_counter', sa.Integer(), nullable=True)) 25 | op.alter_column('user', 'hashed_password', 26 | existing_type=sa.VARCHAR(), 27 | nullable=True) 28 | # ### end Alembic commands ### 29 | 30 | 31 | def downgrade(): 32 | # ### commands auto generated by Alembic - please adjust! ### 33 | op.alter_column('user', 'hashed_password', 34 | existing_type=sa.VARCHAR(), 35 | nullable=False) 36 | op.drop_column('user', 'totp_counter') 37 | op.drop_column('user', 'totp_secret') 38 | op.drop_column('user', 'modified') 39 | op.drop_column('user', 'created') 40 | # ### end Alembic commands ### 41 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/alembic/versions/c4f38069dc24_first_revision.py: -------------------------------------------------------------------------------- 1 | """First revision 2 | 3 | Revision ID: c4f38069dc24 4 | Revises: 5 | Create Date: 2022-12-16 08:09:54.834747 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'c4f38069dc24' 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('user', 22 | sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False), 23 | sa.Column('full_name', sa.String(), nullable=True), 24 | sa.Column('email', sa.String(), nullable=False), 25 | sa.Column('hashed_password', sa.String(), nullable=False), 26 | sa.Column('email_validated', sa.Boolean(), 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('token', 35 | sa.Column('token', sa.String(), nullable=False), 36 | sa.Column('is_valid', sa.Boolean(), nullable=True), 37 | sa.Column('authenticates_id', postgresql.UUID(as_uuid=True), nullable=True), 38 | sa.ForeignKeyConstraint(['authenticates_id'], ['user.id'], ), 39 | sa.PrimaryKeyConstraint('token') 40 | ) 41 | op.create_index(op.f('ix_token_token'), 'token', ['token'], unique=False) 42 | # ### end Alembic commands ### 43 | 44 | 45 | def downgrade(): 46 | # ### commands auto generated by Alembic - please adjust! ### 47 | op.drop_index(op.f('ix_token_token'), table_name='token') 48 | op.drop_table('token') 49 | op.drop_index(op.f('ix_user_id'), table_name='user') 50 | op.drop_index(op.f('ix_user_full_name'), table_name='user') 51 | op.drop_index(op.f('ix_user_email'), table_name='user') 52 | op.drop_table('user') 53 | # ### end Alembic commands ### 54 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/alembic/versions/fb120f8fc198_token_remove_to_invalidate.py: -------------------------------------------------------------------------------- 1 | """Token remove to invalidate 2 | 3 | Revision ID: fb120f8fc198 4 | Revises: 8188d671489a 5 | Create Date: 2023-07-25 11:39:26.423122 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "fb120f8fc198" 14 | down_revision = "8188d671489a" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.alter_column("token", "authenticates_id", 22 | existing_type=sa.UUID(), 23 | nullable=False) 24 | op.drop_column("token", "is_valid") 25 | op.alter_column("user", "created", 26 | existing_type=postgresql.TIMESTAMP(), 27 | type_=sa.DateTime(timezone=True), 28 | existing_nullable=False, 29 | existing_server_default=sa.text("now()")) 30 | op.alter_column("user", "modified", 31 | existing_type=postgresql.TIMESTAMP(), 32 | type_=sa.DateTime(timezone=True), 33 | existing_nullable=False, 34 | existing_server_default=sa.text("now()")) 35 | op.alter_column("user", "email_validated", 36 | existing_type=sa.BOOLEAN(), 37 | nullable=False) 38 | op.alter_column("user", "is_active", 39 | existing_type=sa.BOOLEAN(), 40 | nullable=False) 41 | op.alter_column("user", "is_superuser", 42 | existing_type=sa.BOOLEAN(), 43 | nullable=False) 44 | # ### end Alembic commands ### 45 | 46 | 47 | def downgrade(): 48 | # ### commands auto generated by Alembic - please adjust! ### 49 | op.alter_column("user", "is_superuser", 50 | existing_type=sa.BOOLEAN(), 51 | nullable=True) 52 | op.alter_column("user", "is_active", 53 | existing_type=sa.BOOLEAN(), 54 | nullable=True) 55 | op.alter_column("user", "email_validated", 56 | existing_type=sa.BOOLEAN(), 57 | nullable=True) 58 | op.alter_column("user", "modified", 59 | existing_type=sa.DateTime(timezone=True), 60 | type_=postgresql.TIMESTAMP(), 61 | existing_nullable=False, 62 | existing_server_default=sa.text("now()")) 63 | op.alter_column("user", "created", 64 | existing_type=sa.DateTime(timezone=True), 65 | type_=postgresql.TIMESTAMP(), 66 | existing_nullable=False, 67 | existing_server_default=sa.text("now()")) 68 | op.add_column("token", sa.Column("is_valid", sa.BOOLEAN(), autoincrement=False, nullable=True)) 69 | op.alter_column("token", "authenticates_id", 70 | existing_type=sa.UUID(), 71 | nullable=True) 72 | # ### end Alembic commands ### 73 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/{{cookiecutter.project_slug}}/backend/app/app/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/__version__.py: -------------------------------------------------------------------------------- 1 | __version__="0.1.0" -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/{{cookiecutter.project_slug}}/backend/app/app/api/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/api.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from app.api.api_v1.endpoints import ( 4 | login, 5 | users, 6 | proxy, 7 | ) 8 | 9 | api_router = APIRouter() 10 | api_router.include_router(login.router, prefix="/login", tags=["login"]) 11 | api_router.include_router(users.router, prefix="/users", tags=["users"]) 12 | api_router.include_router(proxy.router, prefix="/proxy", tags=["proxy"]) 13 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/proxy.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, Any 2 | from pydantic import AnyHttpUrl 3 | from fastapi import APIRouter, Depends, HTTPException, Request, Response 4 | import httpx 5 | 6 | from app import models 7 | from app.api import deps 8 | 9 | 10 | router = APIRouter() 11 | 12 | """ 13 | A proxy for the frontend client when hitting cors issues with axios requests. Adjust as required. This version has 14 | a user-login dependency to reduce the risk of leaking the server as a random proxy. 15 | """ 16 | 17 | 18 | @router.post("/{path:path}") 19 | async def proxy_post_request( 20 | *, 21 | path: AnyHttpUrl, 22 | request: Request, 23 | current_user: Annotated[models.User, Depends(deps.get_current_active_user)], 24 | ) -> Any: 25 | # https://www.starlette.io/requests/ 26 | # https://www.python-httpx.org/quickstart/ 27 | # https://github.com/tiangolo/fastapi/issues/1788#issuecomment-698698884 28 | # https://fastapi.tiangolo.com/tutorial/path-params/#__code_13 29 | try: 30 | data = await request.json() 31 | headers = { 32 | "Content-Type": request.headers.get("Content-Type"), 33 | "Authorization": request.headers.get("Authorization"), 34 | } 35 | async with httpx.AsyncClient() as client: 36 | proxy = await client.post(f"{path}", headers=headers, data=data) 37 | response = Response(content=proxy.content, status_code=proxy.status_code) 38 | return response 39 | except Exception as e: 40 | raise HTTPException(status_code=403, detail=str(e)) 41 | 42 | 43 | @router.get("/{path:path}") 44 | async def proxy_get_request( 45 | *, 46 | path: AnyHttpUrl, 47 | request: Request, 48 | current_user: Annotated[models.User, Depends(deps.get_current_active_user)], 49 | ) -> Any: 50 | try: 51 | headers = { 52 | "Content-Type": request.headers.get("Content-Type", "application/x-www-form-urlencoded"), 53 | "Authorization": request.headers.get("Authorization"), 54 | } 55 | async with httpx.AsyncClient() as client: 56 | proxy = await client.get(f"{path}", headers=headers) 57 | response = Response(content=proxy.content, status_code=proxy.status_code) 58 | return response 59 | except Exception as e: 60 | raise HTTPException(status_code=403, detail=str(e)) 61 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/services.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi import APIRouter 4 | 5 | from app import schemas 6 | from app.utilities import send_web_contact_email 7 | from app.schemas import EmailContent 8 | 9 | router = APIRouter() 10 | 11 | 12 | @router.post("/contact", response_model=schemas.Msg, status_code=201) 13 | def send_email(*, data: EmailContent) -> Any: 14 | """ 15 | Standard app contact us. 16 | """ 17 | send_web_contact_email(data=data) 18 | return {"msg": "Web contact email sent"} 19 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/sockets.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from fastapi import WebSocket 3 | from starlette.websockets import WebSocketDisconnect, WebSocketException 4 | 5 | # from websockets.exceptions import ConnectionClosedError 6 | 7 | 8 | async def send_response(*, websocket: WebSocket, response: dict): 9 | try: 10 | await websocket.send_json(response) 11 | return True 12 | except (WebSocketDisconnect, WebSocketException): 13 | return False 14 | 15 | 16 | async def receive_request(*, websocket: WebSocket) -> dict: 17 | try: 18 | return await websocket.receive_json() 19 | except (WebSocketDisconnect, WebSocketException): 20 | return {} 21 | 22 | 23 | def sanitize_data_request(data: any) -> any: 24 | # Putting here for want of a better place 25 | if isinstance(data, (list, tuple, set)): 26 | return type(data)(sanitize_data_request(x) for x in data if x or isinstance(x, bool)) 27 | elif isinstance(data, dict): 28 | return type(data)( 29 | (sanitize_data_request(k), sanitize_data_request(v)) 30 | for k, v in data.items() 31 | if k and v or isinstance(v, bool) 32 | ) 33 | else: 34 | return data 35 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/backend_pre_start.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed 4 | from sqlalchemy.sql import text 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/celeryworker_pre_start.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed 4 | from sqlalchemy.sql import text 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/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/{{cookiecutter.project_slug}}/backend/app/app/core/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/core/celery_app.py: -------------------------------------------------------------------------------- 1 | from celery import Celery 2 | 3 | celery_app = Celery("worker", broker="amqp://guest@queue//") 4 | 5 | celery_app.conf.task_routes = {"app.worker.*": "main-queue"} 6 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py: -------------------------------------------------------------------------------- 1 | from .crud_user import user 2 | from .crud_token import token 3 | 4 | 5 | # For a new basic set of CRUD operations you could just do 6 | 7 | # from .base import CRUDBase 8 | # from app.models.item import Item 9 | # from app.schemas.item import ItemCreate, ItemUpdate 10 | 11 | # item = CRUDBase[Item, ItemCreate, ItemUpdate](Item) 12 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/crud/base.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Generic, Optional, Type, TypeVar, Union 2 | 3 | from fastapi.encoders import jsonable_encoder 4 | from pydantic import BaseModel 5 | from sqlalchemy.orm import Session 6 | 7 | from app.db.base_class import Base 8 | from app.core.config import settings 9 | 10 | ModelType = TypeVar("ModelType", bound=Base) 11 | CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) 12 | UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) 13 | 14 | 15 | class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): 16 | def __init__(self, model: Type[ModelType]): 17 | """ 18 | CRUD object with default methods to Create, Read, Update, Delete (CRUD). 19 | 20 | **Parameters** 21 | 22 | * `model`: A SQLAlchemy model class 23 | * `schema`: A Pydantic model (schema) class 24 | """ 25 | self.model = model 26 | 27 | def get(self, db: Session, id: Any) -> Optional[ModelType]: 28 | return db.query(self.model).filter(self.model.id == id).first() 29 | 30 | def get_multi(self, db: Session, *, page: int = 0, page_break: bool = False) -> list[ModelType]: 31 | db_objs = db.query(self.model) 32 | if not page_break: 33 | if page > 0: 34 | db_objs = db_objs.offset(page * settings.MULTI_MAX) 35 | db_objs = db_objs.limit(settings.MULTI_MAX) 36 | return db_objs.all() 37 | 38 | def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType: 39 | obj_in_data = jsonable_encoder(obj_in) 40 | db_obj = self.model(**obj_in_data) # type: ignore 41 | db.add(db_obj) 42 | db.commit() 43 | db.refresh(db_obj) 44 | return db_obj 45 | 46 | def update( 47 | self, 48 | db: Session, 49 | *, 50 | db_obj: ModelType, 51 | obj_in: Union[UpdateSchemaType, Dict[str, Any]] 52 | ) -> ModelType: 53 | obj_data = jsonable_encoder(db_obj) 54 | if isinstance(obj_in, dict): 55 | update_data = obj_in 56 | else: 57 | update_data = obj_in.dict(exclude_unset=True) 58 | for field in obj_data: 59 | if field in update_data: 60 | setattr(db_obj, field, update_data[field]) 61 | db.add(db_obj) 62 | db.commit() 63 | db.refresh(db_obj) 64 | return db_obj 65 | 66 | def remove(self, db: Session, *, id: int) -> ModelType: 67 | obj = db.query(self.model).get(id) 68 | db.delete(obj) 69 | db.commit() 70 | return obj 71 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/crud/crud_token.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from sqlalchemy.orm import Session 3 | 4 | from app.crud.base import CRUDBase 5 | from app.models import User, Token 6 | from app.schemas import RefreshTokenCreate, RefreshTokenUpdate 7 | from app.core.config import settings 8 | 9 | 10 | class CRUDToken(CRUDBase[Token, RefreshTokenCreate, RefreshTokenUpdate]): 11 | # Everything is user-dependent 12 | def create(self, db: Session, *, obj_in: str, user_obj: User) -> Token: 13 | db_obj = db.query(self.model).filter(self.model.token == obj_in).first() 14 | if db_obj and db_obj.authenticates != user_obj: 15 | raise ValueError("Token mismatch between key and user.") 16 | obj_in = RefreshTokenCreate(**{"token": obj_in, "authenticates_id": user_obj.id}) 17 | return super().create(db=db, obj_in=obj_in) 18 | 19 | def get(self, *, user: User, token: str) -> Token: 20 | return user.refresh_tokens.filter(self.model.token == token).first() 21 | 22 | def get_multi(self, *, user: User, page: int = 0, page_break: bool = False) -> list[Token]: 23 | db_objs = user.refresh_tokens 24 | if not page_break: 25 | if page > 0: 26 | db_objs = db_objs.offset(page * settings.MULTI_MAX) 27 | db_objs = db_objs.limit(settings.MULTI_MAX) 28 | return db_objs.all() 29 | 30 | def remove(self, db: Session, *, db_obj: Token) -> None: 31 | db.delete(db_obj) 32 | db.commit() 33 | return None 34 | 35 | token = CRUDToken(Token) 36 | -------------------------------------------------------------------------------- /{{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, UserInDB, UserUpdate 9 | from app.schemas.totp import NewTOTP 10 | 11 | 12 | class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]): 13 | def get_by_email(self, db: Session, *, email: str) -> Optional[User]: 14 | return db.query(User).filter(User.email == email).first() 15 | 16 | def create(self, db: Session, *, obj_in: UserCreate) -> User: 17 | db_obj = User( 18 | email=obj_in.email, 19 | hashed_password=get_password_hash(obj_in.password) if obj_in.password is not None else None, 20 | full_name=obj_in.full_name, 21 | is_superuser=obj_in.is_superuser, 22 | ) 23 | db.add(db_obj) 24 | db.commit() 25 | db.refresh(db_obj) 26 | return db_obj 27 | 28 | def update(self, db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]]) -> User: 29 | if isinstance(obj_in, dict): 30 | update_data = obj_in 31 | else: 32 | update_data = obj_in.model_dump(exclude_unset=True) 33 | if update_data.get("password"): 34 | hashed_password = get_password_hash(update_data["password"]) 35 | del update_data["password"] 36 | update_data["hashed_password"] = hashed_password 37 | if update_data.get("email") and db_obj.email != update_data["email"]: 38 | update_data["email_validated"] = False 39 | return super().update(db, db_obj=db_obj, obj_in=update_data) 40 | 41 | def authenticate(self, db: Session, *, email: str, password: str) -> Optional[User]: 42 | user = self.get_by_email(db, email=email) 43 | if not user: 44 | return None 45 | if not verify_password(plain_password=password, hashed_password=user.hashed_password): 46 | return None 47 | return user 48 | 49 | def validate_email(self, db: Session, *, db_obj: User) -> User: 50 | obj_in = UserUpdate(**UserInDB.model_validate(db_obj).model_dump()) 51 | obj_in.email_validated = True 52 | return self.update(db=db, db_obj=db_obj, obj_in=obj_in) 53 | 54 | def activate_totp(self, db: Session, *, db_obj: User, totp_in: NewTOTP) -> User: 55 | obj_in = UserUpdate(**UserInDB.model_validate(db_obj).model_dump()) 56 | obj_in = obj_in.model_dump(exclude_unset=True) 57 | obj_in["totp_secret"] = totp_in.secret 58 | return self.update(db=db, db_obj=db_obj, obj_in=obj_in) 59 | 60 | def deactivate_totp(self, db: Session, *, db_obj: User) -> User: 61 | obj_in = UserUpdate(**UserInDB.model_validate(db_obj).model_dump()) 62 | obj_in = obj_in.model_dump(exclude_unset=True) 63 | obj_in["totp_secret"] = None 64 | obj_in["totp_counter"] = None 65 | return self.update(db=db, db_obj=db_obj, obj_in=obj_in) 66 | 67 | def update_totp_counter(self, db: Session, *, db_obj: User, new_counter: int) -> User: 68 | obj_in = UserUpdate(**UserInDB.model_validate(db_obj).model_dump()) 69 | obj_in = obj_in.model_dump(exclude_unset=True) 70 | obj_in["totp_counter"] = new_counter 71 | return self.update(db=db, db_obj=db_obj, obj_in=obj_in) 72 | 73 | def toggle_user_state(self, db: Session, *, obj_in: Union[UserUpdate, Dict[str, Any]]) -> User: 74 | db_obj = self.get_by_email(db, email=obj_in.email) 75 | if not db_obj: 76 | return None 77 | return self.update(db=db, db_obj=db_obj, obj_in=obj_in) 78 | 79 | def has_password(self, user: User) -> bool: 80 | if user.hashed_password: 81 | return True 82 | return False 83 | 84 | def is_active(self, user: User) -> bool: 85 | return user.is_active 86 | 87 | def is_superuser(self, user: User) -> bool: 88 | return user.is_superuser 89 | 90 | def is_email_validated(self, user: User) -> bool: 91 | return user.email_validated 92 | 93 | 94 | user = CRUDUser(User) 95 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/{{cookiecutter.project_slug}}/backend/app/app/db/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/db/base.py: -------------------------------------------------------------------------------- 1 | # Import all the models, so that Base has them before being 2 | # imported by Alembic 3 | from app.db.base_class import Base # noqa 4 | from app.models.user import User # noqa 5 | from app.models.token import Token # noqa 6 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/db/base_class.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from sqlalchemy.orm import DeclarativeBase, declared_attr 4 | 5 | 6 | class Base(DeclarativeBase): 7 | id: Any 8 | __name__: str 9 | 10 | # Generate __tablename__ automatically 11 | @declared_attr 12 | def __tablename__(cls) -> str: 13 | return cls.__name__.lower() 14 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/db/init_db.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Session 2 | 3 | from app import crud, schemas 4 | from app.core.config import settings 5 | from app.db import base # noqa: F401 6 | 7 | # make sure all SQL Alchemy models are imported (app.db.base) before initializing DB 8 | # otherwise, SQL Alchemy might fail to initialize relationships properly 9 | # for more details: https://github.com/tiangolo/full-stack-fastapi-postgresql/issues/28 10 | 11 | 12 | def init_db(db: Session) -> None: 13 | # Tables should be created with Alembic migrations 14 | # But if you don't want to use migrations, create 15 | # the tables un-commenting the next line 16 | # Base.metadata.create_all(bind=engine) 17 | 18 | user = crud.user.get_by_email(db, email=settings.FIRST_SUPERUSER) 19 | if not user: 20 | # Create user auth 21 | user_in = schemas.UserCreate( 22 | email=settings.FIRST_SUPERUSER, 23 | password=settings.FIRST_SUPERUSER_PASSWORD, 24 | is_superuser=True, 25 | ) 26 | user = crud.user.create(db, obj_in=user_in) # noqa: F841 27 | -------------------------------------------------------------------------------- /{{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/email-templates/build/test_email.html: -------------------------------------------------------------------------------- 1 |

{{ project_name }}
Test email for: {{ email }}
-------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/email-templates/src/confirm_email.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | Confirm your email address 14 | Hi, 17 | You'll need to validate this email address before you can offer your 19 | work. All you need to do is click this button. 21 | 27 | Confirm your email 28 | 29 | Or open the following link: 32 | {{ link }} 35 | If you have no idea what this is about, then don't worry. You can 37 | safely ignore and delete this email. 39 | For any concerns or queries, just email me. 42 | {{ server_bot }} @ {{ server_name }} 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/email-templates/src/magic_login.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | Log in to {{ project_name }} 14 | Welcome! Login to your account by clicking the button below within the next {{ valid_minutes }} minutes: 17 | 23 | Login 24 | 25 | Or copy the following link and paste it in your browser: 28 | {{ link }} 31 | 32 | Make sure you use this code on the same device and in the same browser where you made this request or it won't work. 35 | 36 | For any concerns or queries, especially if you didn't make this request or feel you received it by mistake, just email me. 39 | {{ server_bot }} @ {{ server_name }} 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/email-templates/src/new_account.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | {{ project_name }} - New Account 12 | Hi, 15 | You have a new account: 16 | Username: {{ username }} 17 | Password: {{ password }} 18 | 24 | Go to Dashboard 25 | 26 | If you have no idea what this is about, then don't worry. You can 28 | safely ignore and delete this email. 30 | For any concerns or queries, just email me. 33 | {{ server_bot }} @ {{ server_name }} 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/email-templates/src/reset_password.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | {{ project_name }} - Password Recovery 14 | We received a request to recover the password for user {{ username }} 16 | with email {{ email }} 18 | Reset your password by clicking the button below: 21 | 27 | Reset Password 28 | 29 | Or open the following link: 32 | {{ link }} 35 | 36 | The reset password link / button will expire in {{ valid_hours }} 38 | hours. 40 | If you didn't request a password recovery you can disregard this 42 | email. 44 | For any concerns or queries, just email me. 47 | {{ server_bot }} @ {{ server_name }} 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/email-templates/src/test_email.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ project_name }} 7 | Test email for: {{ email }} 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/email-templates/src/web_contact_email.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | Email from: {{ email }} 14 | {{ content }} 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/gdb/__init__.py: -------------------------------------------------------------------------------- 1 | from .base_node_class import NodeBase, MetadataBase 2 | from .neomodel_config import NeomodelConfig 3 | from .init_gdb import init_gdb 4 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/gdb/base_edge_class.py: -------------------------------------------------------------------------------- 1 | from neomodel import ( 2 | StructuredRel, 3 | BooleanProperty, 4 | DateTimeProperty, 5 | ) 6 | from datetime import datetime 7 | import pytz 8 | 9 | 10 | class ResourceRelationship(StructuredRel): 11 | created = DateTimeProperty(default=lambda: datetime.now(pytz.utc)) 12 | isActive = BooleanProperty(default=True) 13 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/gdb/init_gdb.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from neomodel import StructuredNode, install_labels, db 3 | from neo4j.exceptions import ClientError 4 | 5 | # from neomodel import config 6 | # from app.core.config import settings 7 | 8 | import app.models 9 | 10 | # from app.gdb import NeomodelConfig 11 | 12 | # NeomodelConfig().ready() 13 | 14 | # config.DATABASE_URL = "bolt://neo4j:neo4j@127.0.0.1:7687" 15 | # config.FORCE_TIMEZONE = settings.NEO4J_FORCE_TIMEZONE 16 | # config.MAX_CONNECTION_POOL_SIZE = settings.NEO4J_MAX_CONNECTION_POOL_SIZE 17 | 18 | 19 | def createNodeIndices(): 20 | """Create indexes for: 21 | Node: field_name1, field_name2 22 | With analyzer: StandardAnalyzer ('standard') 23 | Update as required. 24 | """ 25 | indices = [ 26 | # ("indexname1", "Node", "field_name1", "simple"), 27 | # ("indexname2", "Node", "field_name2" , "standard"), 28 | ] 29 | for index, node, key, analyzer in indices: 30 | try: 31 | q = f"CALL db.index.fulltext.createNodeIndex('{index}',['{node}'],['{key}'], {{analyzer: '{analyzer}'}})" 32 | db.cypher_query(q) 33 | except ClientError: 34 | pass 35 | 36 | 37 | def dropNodeIndices(): 38 | indices = ["indexname1", "indexname2"] 39 | for index in indices: 40 | try: 41 | q = f"CALL db.index.fulltext.drop('{index}')" 42 | db.cypher_query(q) 43 | except ClientError: 44 | pass 45 | 46 | 47 | def init_gdb() -> None: 48 | # Neo4j / neomodel requires nodes to be created, but labels on the nodes 49 | # can be created at run-time without a specific migration step 50 | # https://stackoverflow.com/questions/1796180/how-can-i-get-a-list-of-all-classes-within-current-module-in-python 51 | for node in [ 52 | node 53 | for _, node in inspect.getmembers(app.models) 54 | if inspect.isclass(node) and issubclass(node, (StructuredNode)) 55 | ]: 56 | try: 57 | install_labels(node) 58 | except ClientError as e: 59 | if not str(e.message).lower().startswith("an equivalent constraint already exists"): 60 | raise e 61 | # createNodeIndices() 62 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/gdb/neomodel_config.py: -------------------------------------------------------------------------------- 1 | from neomodel import config 2 | 3 | from app.core.config import settings 4 | 5 | 6 | class NeomodelConfig: 7 | def read_settings(self) -> None: 8 | # https://stackoverflow.com/a/64309171/295606 9 | # https://stackoverflow.com/a/66408057/295606 10 | # https://community.neo4j.com/t/troubleshooting-connection-issues-to-neo4j/129/10 11 | # Docker very non-obvious ... reach neo4j container by calling the container name 12 | config.DATABASE_URL = settings.NEO4J_BOLT_URL 13 | config.FORCE_TIMEZONE = settings.NEO4J_FORCE_TIMEZONE 14 | config.AUTO_INSTALL_LABELS = settings.NEO4J_AUTO_INSTALL_LABELS 15 | config.MAX_CONNECTION_POOL_SIZE = settings.NEO4J_MAX_CONNECTION_POOL_SIZE 16 | 17 | def ready(self) -> None: 18 | self.read_settings() 19 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/initial_data.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | import json 4 | from passlib.totp import generate_secret 5 | 6 | from app.gdb.init_gdb import init_gdb 7 | from app.db.init_db import init_db 8 | from app.db.session import SessionLocal 9 | from app.gdb import NeomodelConfig 10 | from app.core.config import settings 11 | 12 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed 13 | 14 | logging.basicConfig(level=logging.INFO) 15 | logger = logging.getLogger(__name__) 16 | 17 | max_tries = 60 * 5 # 5 minutes 18 | wait_seconds = 1 19 | 20 | 21 | @retry( 22 | stop=stop_after_attempt(max_tries), 23 | wait=wait_fixed(wait_seconds), 24 | before=before_log(logger, logging.INFO), 25 | after=after_log(logger, logging.WARN), 26 | ) 27 | def initNeo4j() -> None: 28 | try: 29 | NeomodelConfig().ready() 30 | init_gdb() 31 | except Exception as e: 32 | logger.error(e) 33 | raise e 34 | 35 | 36 | def init() -> None: 37 | db = SessionLocal() 38 | init_db(db) 39 | 40 | 41 | def main() -> None: 42 | logger.info("Creating initial data") 43 | initNeo4j() 44 | init() 45 | logger.info("Initial data created") 46 | 47 | 48 | if __name__ == "__main__": 49 | main() 50 | -------------------------------------------------------------------------------- /{{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 | # from app.gdb import NeomodelConfig 7 | 8 | app = FastAPI( 9 | title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json" 10 | ) 11 | 12 | # Set all CORS enabled origins 13 | if settings.BACKEND_CORS_ORIGINS: 14 | app.add_middleware( 15 | CORSMiddleware, 16 | allow_origins=[str(origin).strip("/") for origin in settings.BACKEND_CORS_ORIGINS], 17 | allow_credentials=True, 18 | allow_methods=["*"], 19 | allow_headers=["*"], 20 | ) 21 | 22 | app.include_router(api_router, prefix=settings.API_V1_STR) 23 | 24 | # nmc = NeomodelConfig() 25 | # nmc.ready() 26 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import User 2 | from .token import Token 3 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/models/token.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING 3 | from sqlalchemy.orm import Mapped, mapped_column, relationship 4 | from sqlalchemy import ForeignKey 5 | from sqlalchemy.dialects.postgresql import UUID 6 | 7 | from app.db.base_class import Base 8 | 9 | if TYPE_CHECKING: 10 | from .user import User # noqa: F401 11 | 12 | 13 | class Token(Base): 14 | token: Mapped[str] = mapped_column(primary_key=True, index=True) 15 | authenticates_id: Mapped[UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("user.id")) 16 | authenticates: Mapped["User"] = relationship(back_populates="refresh_tokens") 17 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/models/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING, Optional 3 | from datetime import datetime 4 | from sqlalchemy.orm import Mapped, mapped_column, relationship 5 | from sqlalchemy import DateTime 6 | from sqlalchemy.sql import func 7 | from sqlalchemy.dialects.postgresql import UUID 8 | from uuid import uuid4 9 | 10 | from app.db.base_class import Base 11 | 12 | if TYPE_CHECKING: 13 | from . import Token # noqa: F401 14 | 15 | 16 | class User(Base): 17 | id: Mapped[UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid4) 18 | created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) 19 | modified: Mapped[datetime] = mapped_column( 20 | DateTime(timezone=True), 21 | server_default=func.now(), 22 | server_onupdate=func.now(), 23 | nullable=False, 24 | ) 25 | # METADATA 26 | full_name: Mapped[str] = mapped_column(index=True, nullable=True) 27 | email: Mapped[str] = mapped_column(unique=True, index=True, nullable=False) 28 | hashed_password: Mapped[Optional[str]] = mapped_column(nullable=True) 29 | # AUTHENTICATION AND PERSISTENCE 30 | totp_secret: Mapped[Optional[str]] = mapped_column(nullable=True) 31 | totp_counter: Mapped[Optional[int]] = mapped_column(nullable=True) 32 | email_validated: Mapped[bool] = mapped_column(default=False) 33 | is_active: Mapped[bool] = mapped_column(default=True) 34 | is_superuser: Mapped[bool] = mapped_column(default=False) 35 | refresh_tokens: Mapped[list["Token"]] = relationship( 36 | foreign_keys="[Token.authenticates_id]", back_populates="authenticates", lazy="dynamic" 37 | ) 38 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/schema_types/__init__.py: -------------------------------------------------------------------------------- 1 | from .base_type import BaseEnum 2 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/schema_types/base_type.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class BaseEnum(str, Enum): 5 | # noinspection PyMethodParameters 6 | # cf https://gitter.im/tiangolo/fastapi?at=5d775f4050508949d30b6eec 7 | def _generate_next_value_(name, start, count, last_values) -> str: # type: ignore 8 | """ 9 | Uses the name as the automatic value, rather than an integer 10 | 11 | See https://docs.python.org/3/library/enum.html#using-automatic-values for reference 12 | """ 13 | return name 14 | 15 | @classmethod 16 | def as_dict(cls): 17 | member_dict = {role: member.value for role, member in cls.__members__.items()} 18 | return member_dict 19 | 20 | @classmethod 21 | def _missing_(cls, value): 22 | # https://stackoverflow.com/a/68311691/295606 23 | for member in cls: 24 | if member.value.upper() == value.upper(): 25 | return member 26 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | from .base_schema import BaseSchema, MetadataBaseSchema, MetadataBaseCreate, MetadataBaseUpdate, MetadataBaseInDBBase 2 | from .msg import Msg 3 | from .token import ( 4 | RefreshTokenCreate, 5 | RefreshTokenUpdate, 6 | RefreshToken, 7 | Token, 8 | TokenPayload, 9 | MagicTokenPayload, 10 | WebToken, 11 | ) 12 | from .user import User, UserCreate, UserInDB, UserUpdate, UserLogin 13 | from .emails import EmailContent, EmailValidation 14 | from .totp import NewTOTP, EnableTOTP 15 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/schemas/base_schema.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from pydantic import ConfigDict, BaseModel, Field 3 | from typing import Optional 4 | from uuid import UUID 5 | from datetime import date, datetime 6 | import json 7 | 8 | from app.schema_types import BaseEnum 9 | 10 | 11 | class BaseSchema(BaseModel): 12 | @property 13 | def as_db_dict(self): 14 | to_db = self.model_dump(exclude_defaults=True, exclude_none=True, exclude={"identifier, id"}) 15 | for key in ["id", "identifier"]: 16 | if key in self.model_dump().keys(): 17 | to_db[key] = self.model_dump()[key].hex 18 | return to_db 19 | 20 | @property 21 | def as_neo_dict(self): 22 | to_db = self.json(exclude_defaults=True, exclude_none=True, exclude={"identifier, id"}) 23 | to_db = json.loads(to_db) 24 | self_dict = self.dict() 25 | for key in self_dict.keys(): 26 | if isinstance(self_dict[key], BaseEnum): 27 | # Uppercase the Enum values 28 | to_db[key] = to_db[key].upper() 29 | if isinstance(self_dict[key], datetime): 30 | to_db[key] = datetime.fromisoformat(to_db[key]) 31 | if isinstance(self_dict[key], date): 32 | to_db[key] = date.fromisoformat(to_db[key]) 33 | if key in ["id", "identifier"]: 34 | to_db[key] = self_dict[key].hex 35 | return to_db 36 | 37 | 38 | class MetadataBaseSchema(BaseSchema): 39 | # Receive via API 40 | # https://www.dublincore.org/specifications/dublin-core/dcmi-terms/#section-3 41 | title: Optional[str] = Field(None, description="A human-readable title given to the resource.") 42 | description: Optional[str] = Field( 43 | None, 44 | description="A short description of the resource.", 45 | ) 46 | isActive: Optional[bool] = Field(default=True, description="Whether the resource is still actively maintained.") 47 | isPrivate: Optional[bool] = Field( 48 | default=True, description="Whether the resource is private to team members with appropriate authorisation." 49 | ) 50 | 51 | 52 | class MetadataBaseCreate(MetadataBaseSchema): 53 | pass 54 | 55 | 56 | class MetadataBaseUpdate(MetadataBaseSchema): 57 | identifier: UUID = Field(..., description="Automatically generated unique identity for the resource.") 58 | 59 | 60 | class MetadataBaseInDBBase(MetadataBaseSchema): 61 | # Identifier managed programmatically 62 | identifier: UUID = Field(..., description="Automatically generated unique identity for the resource.") 63 | created: date = Field(..., description="Automatically generated date resource was created.") 64 | isActive: bool = Field(..., description="Whether the resource is still actively maintained.") 65 | isPrivate: bool = Field( 66 | ..., description="Whether the resource is private to team members with appropriate authorisation." 67 | ) 68 | model_config = ConfigDict(from_attributes=True) 69 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/schemas/emails.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, EmailStr 2 | 3 | 4 | class EmailContent(BaseModel): 5 | email: EmailStr 6 | subject: str 7 | content: str 8 | 9 | 10 | class EmailValidation(BaseModel): 11 | email: EmailStr 12 | subject: str 13 | token: str 14 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/schemas/msg.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class Msg(BaseModel): 5 | msg: str 6 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/schemas/token.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from pydantic import ConfigDict, BaseModel 3 | from uuid import UUID 4 | 5 | 6 | class RefreshTokenBase(BaseModel): 7 | token: str 8 | authenticates_id: Optional[UUID] = None 9 | 10 | 11 | class RefreshTokenCreate(RefreshTokenBase): 12 | authenticates_id: UUID 13 | 14 | 15 | class RefreshTokenUpdate(RefreshTokenBase): 16 | pass 17 | 18 | 19 | class RefreshToken(RefreshTokenUpdate): 20 | model_config = ConfigDict(from_attributes=True) 21 | 22 | 23 | class Token(BaseModel): 24 | access_token: str 25 | refresh_token: Optional[str] = None 26 | token_type: str 27 | 28 | 29 | class TokenPayload(BaseModel): 30 | sub: Optional[UUID] = None 31 | refresh: Optional[bool] = False 32 | totp: Optional[bool] = False 33 | 34 | 35 | class MagicTokenPayload(BaseModel): 36 | sub: Optional[UUID] = None 37 | fingerprint: Optional[UUID] = None 38 | 39 | 40 | class WebToken(BaseModel): 41 | claim: str 42 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/schemas/totp.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from pydantic import BaseModel 3 | 4 | 5 | class NewTOTP(BaseModel): 6 | secret: Optional[str] = None 7 | key: str 8 | uri: str 9 | 10 | 11 | class EnableTOTP(BaseModel): 12 | claim: str 13 | uri: str 14 | password: Optional[str] = None 15 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/schemas/user.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from uuid import UUID 3 | from pydantic import field_validator, StringConstraints, ConfigDict, BaseModel, Field, EmailStr 4 | from typing_extensions import Annotated 5 | 6 | 7 | class UserLogin(BaseModel): 8 | username: str 9 | password: str 10 | 11 | 12 | # Shared properties 13 | class UserBase(BaseModel): 14 | email: Optional[EmailStr] = None 15 | email_validated: Optional[bool] = False 16 | is_active: Optional[bool] = True 17 | is_superuser: Optional[bool] = False 18 | full_name: Optional[str] = None 19 | 20 | 21 | # Properties to receive via API on creation 22 | class UserCreate(UserBase): 23 | email: EmailStr 24 | password: Optional[Annotated[str, StringConstraints(min_length=8, max_length=64)]] = None 25 | 26 | 27 | # Properties to receive via API on update 28 | class UserUpdate(UserBase): 29 | original: Optional[Annotated[str, StringConstraints(min_length=8, max_length=64)]] = None 30 | password: Optional[Annotated[str, StringConstraints(min_length=8, max_length=64)]] = None 31 | 32 | 33 | class UserInDBBase(UserBase): 34 | id: Optional[UUID] = None 35 | model_config = ConfigDict(from_attributes=True) 36 | 37 | 38 | # Additional properties to return via API 39 | class User(UserInDBBase): 40 | hashed_password: bool = Field(default=False, alias="password") 41 | totp_secret: bool = Field(default=False, alias="totp") 42 | model_config = ConfigDict(populate_by_name=True) 43 | 44 | @field_validator("hashed_password", mode="before") 45 | @classmethod 46 | def evaluate_hashed_password(cls, hashed_password): 47 | if hashed_password: 48 | return True 49 | return False 50 | 51 | @field_validator("totp_secret", mode="before") 52 | @classmethod 53 | def evaluate_totp_secret(cls, totp_secret): 54 | if totp_secret: 55 | return True 56 | return False 57 | 58 | 59 | # Additional properties stored in DB 60 | class UserInDB(UserInDBBase): 61 | hashed_password: Optional[str] = None 62 | totp_secret: Optional[str] = None 63 | totp_counter: Optional[int] = None 64 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/{{cookiecutter.project_slug}}/backend/app/app/tests/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/{{cookiecutter.project_slug}}/backend/app/app/tests/api/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_celery.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from fastapi.testclient import TestClient 4 | 5 | from app.core.config import settings 6 | 7 | 8 | def test_celery_worker_test( 9 | client: TestClient, superuser_token_headers: Dict[str, str] 10 | ) -> None: 11 | data = {"msg": "test"} 12 | r = client.post( 13 | f"{settings.API_V1_STR}/utils/test-celery/", 14 | json=data, 15 | headers=superuser_token_headers, 16 | ) 17 | response = r.json() 18 | assert response["msg"] == "Word received" 19 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_items.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | from sqlalchemy.orm import Session 3 | 4 | from app.core.config import settings 5 | from app.tests.utils.item import create_random_item 6 | 7 | 8 | def test_create_item( 9 | client: TestClient, superuser_token_headers: dict, db: Session 10 | ) -> None: 11 | data = {"title": "Foo", "description": "Fighters"} 12 | response = client.post( 13 | f"{settings.API_V1_STR}/items/", headers=superuser_token_headers, json=data, 14 | ) 15 | assert response.status_code == 200 16 | content = response.json() 17 | assert content["title"] == data["title"] 18 | assert content["description"] == data["description"] 19 | assert "id" in content 20 | assert "owner_id" in content 21 | 22 | 23 | def test_read_item( 24 | client: TestClient, superuser_token_headers: dict, db: Session 25 | ) -> None: 26 | item = create_random_item(db) 27 | response = client.get( 28 | f"{settings.API_V1_STR}/items/{item.id}", headers=superuser_token_headers, 29 | ) 30 | assert response.status_code == 200 31 | content = response.json() 32 | assert content["title"] == item.title 33 | assert content["description"] == item.description 34 | assert content["id"] == item.id 35 | assert content["owner_id"] == item.owner_id 36 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_login.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from fastapi.testclient import TestClient 4 | 5 | from app.core.config import settings 6 | 7 | 8 | def test_get_access_token(client: TestClient) -> None: 9 | login_data = { 10 | "username": settings.FIRST_SUPERUSER, 11 | "password": settings.FIRST_SUPERUSER_PASSWORD, 12 | } 13 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) 14 | tokens = r.json() 15 | assert r.status_code == 200 16 | assert "access_token" in tokens 17 | assert tokens["access_token"] 18 | 19 | 20 | def test_use_access_token( 21 | client: TestClient, superuser_token_headers: Dict[str, str] 22 | ) -> None: 23 | r = client.post( 24 | f"{settings.API_V1_STR}/login/test-token", headers=superuser_token_headers, 25 | ) 26 | result = r.json() 27 | assert r.status_code == 200 28 | assert "email" in result 29 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Generator 2 | 3 | import pytest 4 | from fastapi.testclient import TestClient 5 | from sqlalchemy.orm import Session 6 | 7 | from app.core.config import settings 8 | from app.db.session import SessionLocal 9 | from app.main import app 10 | from app.tests.utils.user import authentication_token_from_email 11 | from app.tests.utils.utils import get_superuser_token_headers 12 | 13 | 14 | @pytest.fixture(scope="session") 15 | def db() -> Generator: 16 | yield SessionLocal() 17 | 18 | 19 | @pytest.fixture(scope="module") 20 | def client() -> Generator: 21 | with TestClient(app) as c: 22 | yield c 23 | 24 | 25 | @pytest.fixture(scope="module") 26 | def superuser_token_headers(client: TestClient) -> Dict[str, str]: 27 | return get_superuser_token_headers(client) 28 | 29 | 30 | @pytest.fixture(scope="module") 31 | def normal_user_token_headers(client: TestClient, db: Session) -> Dict[str, str]: 32 | return authentication_token_from_email( 33 | client=client, email=settings.EMAIL_TEST_USER, db=db 34 | ) 35 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/crud/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_item.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Session 2 | 3 | from app import crud 4 | from app.schemas.item import ItemCreate, ItemUpdate 5 | from app.tests.utils.user import create_random_user 6 | from app.tests.utils.utils import random_lower_string 7 | 8 | 9 | def test_create_item(db: Session) -> None: 10 | title = random_lower_string() 11 | description = random_lower_string() 12 | item_in = ItemCreate(title=title, description=description) 13 | user = create_random_user(db) 14 | item = crud.item.create_with_owner(db=db, obj_in=item_in, owner_id=user.id) 15 | assert item.title == title 16 | assert item.description == description 17 | assert item.owner_id == user.id 18 | 19 | 20 | def test_get_item(db: Session) -> None: 21 | title = random_lower_string() 22 | description = random_lower_string() 23 | item_in = ItemCreate(title=title, description=description) 24 | user = create_random_user(db) 25 | item = crud.item.create_with_owner(db=db, obj_in=item_in, owner_id=user.id) 26 | stored_item = crud.item.get(db=db, id=item.id) 27 | assert stored_item 28 | assert item.id == stored_item.id 29 | assert item.title == stored_item.title 30 | assert item.description == stored_item.description 31 | assert item.owner_id == stored_item.owner_id 32 | 33 | 34 | def test_update_item(db: Session) -> None: 35 | title = random_lower_string() 36 | description = random_lower_string() 37 | item_in = ItemCreate(title=title, description=description) 38 | user = create_random_user(db) 39 | item = crud.item.create_with_owner(db=db, obj_in=item_in, owner_id=user.id) 40 | description2 = random_lower_string() 41 | item_update = ItemUpdate(description=description2) 42 | item2 = crud.item.update(db=db, db_obj=item, obj_in=item_update) 43 | assert item.id == item2.id 44 | assert item.title == item2.title 45 | assert item2.description == description2 46 | assert item.owner_id == item2.owner_id 47 | 48 | 49 | def test_delete_item(db: Session) -> None: 50 | title = random_lower_string() 51 | description = random_lower_string() 52 | item_in = ItemCreate(title=title, description=description) 53 | user = create_random_user(db) 54 | item = crud.item.create_with_owner(db=db, obj_in=item_in, owner_id=user.id) 55 | item2 = crud.item.remove(db=db, id=item.id) 56 | item3 = crud.item.get(db=db, id=item.id) 57 | assert item3 is None 58 | assert item2.id == item.id 59 | assert item2.title == title 60 | assert item2.description == description 61 | assert item2.owner_id == user.id 62 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_user.py: -------------------------------------------------------------------------------- 1 | from fastapi.encoders import jsonable_encoder 2 | from sqlalchemy.orm import Session 3 | 4 | from app import crud 5 | from app.core.security import verify_password 6 | from app.schemas.user import UserCreate, UserUpdate 7 | from app.tests.utils.utils import random_email, random_lower_string 8 | 9 | 10 | def test_create_user(db: Session) -> None: 11 | email = random_email() 12 | password = random_lower_string() 13 | user_in = UserCreate(email=email, password=password) 14 | user = crud.user.create(db, obj_in=user_in) 15 | assert user.email == email 16 | assert hasattr(user, "hashed_password") 17 | 18 | 19 | def test_authenticate_user(db: Session) -> None: 20 | email = random_email() 21 | password = random_lower_string() 22 | user_in = UserCreate(email=email, password=password) 23 | user = crud.user.create(db, obj_in=user_in) 24 | authenticated_user = crud.user.authenticate(db, email=email, password=password) 25 | assert authenticated_user 26 | assert user.email == authenticated_user.email 27 | 28 | 29 | def test_not_authenticate_user(db: Session) -> None: 30 | email = random_email() 31 | password = random_lower_string() 32 | user = crud.user.authenticate(db, email=email, password=password) 33 | assert user is None 34 | 35 | 36 | def test_check_if_user_is_active(db: Session) -> None: 37 | email = random_email() 38 | password = random_lower_string() 39 | user_in = UserCreate(email=email, password=password) 40 | user = crud.user.create(db, obj_in=user_in) 41 | is_active = crud.user.is_active(user) 42 | assert is_active is True 43 | 44 | 45 | def test_check_if_user_is_active_inactive(db: Session) -> None: 46 | email = random_email() 47 | password = random_lower_string() 48 | user_in = UserCreate(email=email, password=password, disabled=True) 49 | user = crud.user.create(db, obj_in=user_in) 50 | is_active = crud.user.is_active(user) 51 | assert is_active 52 | 53 | 54 | def test_check_if_user_is_superuser(db: Session) -> None: 55 | email = random_email() 56 | password = random_lower_string() 57 | user_in = UserCreate(email=email, password=password, is_superuser=True) 58 | user = crud.user.create(db, obj_in=user_in) 59 | is_superuser = crud.user.is_superuser(user) 60 | assert is_superuser is True 61 | 62 | 63 | def test_check_if_user_is_superuser_normal_user(db: Session) -> None: 64 | username = random_email() 65 | password = random_lower_string() 66 | user_in = UserCreate(email=username, password=password) 67 | user = crud.user.create(db, obj_in=user_in) 68 | is_superuser = crud.user.is_superuser(user) 69 | assert is_superuser is False 70 | 71 | 72 | def test_get_user(db: Session) -> None: 73 | password = random_lower_string() 74 | username = random_email() 75 | user_in = UserCreate(email=username, password=password, is_superuser=True) 76 | user = crud.user.create(db, obj_in=user_in) 77 | user_2 = crud.user.get(db, id=user.id) 78 | assert user_2 79 | assert user.email == user_2.email 80 | assert jsonable_encoder(user) == jsonable_encoder(user_2) 81 | 82 | 83 | def test_update_user(db: Session) -> None: 84 | password = random_lower_string() 85 | email = random_email() 86 | user_in = UserCreate(email=email, password=password, is_superuser=True) 87 | user = crud.user.create(db, obj_in=user_in) 88 | new_password = random_lower_string() 89 | user_in_update = UserUpdate(password=new_password, is_superuser=True) 90 | crud.user.update(db, db_obj=user, obj_in=user_in_update) 91 | user_2 = crud.user.get(db, id=user.id) 92 | assert user_2 93 | assert user.email == user_2.email 94 | assert verify_password(new_password, user_2.hashed_password) 95 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/utils/item.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from sqlalchemy.orm import Session 4 | 5 | from app import crud, models 6 | from app.schemas.item import ItemCreate 7 | from app.tests.utils.user import create_random_user 8 | from app.tests.utils.utils import random_lower_string 9 | 10 | 11 | def create_random_item(db: Session, *, owner_id: Optional[int] = None) -> models.Item: 12 | if owner_id is None: 13 | user = create_random_user(db) 14 | owner_id = user.id 15 | title = random_lower_string() 16 | description = random_lower_string() 17 | item_in = ItemCreate(title=title, description=description, id=id) 18 | return crud.item.create_with_owner(db=db, obj_in=item_in, owner_id=owner_id) 19 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/utils/user.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from fastapi.testclient import TestClient 4 | from sqlalchemy.orm import Session 5 | 6 | from app import crud 7 | from app.core.config import settings 8 | from app.models.user import User 9 | from app.schemas.user import UserCreate, UserUpdate 10 | from app.tests.utils.utils import random_email, random_lower_string 11 | 12 | 13 | def user_authentication_headers( 14 | *, client: TestClient, email: str, password: str 15 | ) -> Dict[str, str]: 16 | data = {"username": email, "password": password} 17 | 18 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=data) 19 | response = r.json() 20 | auth_token = response["access_token"] 21 | headers = {"Authorization": f"Bearer {auth_token}"} 22 | return headers 23 | 24 | 25 | def create_random_user(db: Session) -> User: 26 | email = random_email() 27 | password = random_lower_string() 28 | user_in = UserCreate(username=email, email=email, password=password) 29 | user = crud.user.create(db=db, obj_in=user_in) 30 | return user 31 | 32 | 33 | def authentication_token_from_email( 34 | *, client: TestClient, email: str, db: Session 35 | ) -> Dict[str, str]: 36 | """ 37 | Return a valid token for the user with given email. 38 | 39 | If the user doesn't exist it is created first. 40 | """ 41 | password = random_lower_string() 42 | user = crud.user.get_by_email(db, email=email) 43 | if not user: 44 | user_in_create = UserCreate(username=email, email=email, password=password) 45 | user = crud.user.create(db, obj_in=user_in_create) 46 | else: 47 | user_in_update = UserUpdate(password=password) 48 | user = crud.user.update(db, db_obj=user, obj_in=user_in_update) 49 | 50 | return user_authentication_headers(client=client, email=email, password=password) 51 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/utils/utils.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | from typing import Dict 4 | 5 | from fastapi.testclient import TestClient 6 | 7 | from app.core.config import settings 8 | 9 | 10 | def random_lower_string() -> str: 11 | return "".join(random.choices(string.ascii_lowercase, k=32)) 12 | 13 | 14 | def random_email() -> str: 15 | return f"{random_lower_string()}@{random_lower_string()}.com" 16 | 17 | 18 | def get_superuser_token_headers(client: TestClient) -> Dict[str, str]: 19 | login_data = { 20 | "username": settings.FIRST_SUPERUSER, 21 | "password": settings.FIRST_SUPERUSER_PASSWORD, 22 | } 23 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) 24 | tokens = r.json() 25 | a_token = tokens["access_token"] 26 | headers = {"Authorization": f"Bearer {a_token}"} 27 | return headers 28 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests_pre_start.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed 4 | from sqlalchemy.sql import text 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() -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/utilities/__init__.py: -------------------------------------------------------------------------------- 1 | from .email import ( 2 | send_email, 3 | send_test_email, 4 | send_web_contact_email, 5 | send_magic_login_email, 6 | send_reset_password_email, 7 | send_new_account_email, 8 | send_email_validation_email, 9 | ) 10 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/worker/__init__.py: -------------------------------------------------------------------------------- 1 | from app.core.celery_app import celery_app 2 | 3 | from .tests import test_celery 4 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/worker/tests.py: -------------------------------------------------------------------------------- 1 | from raven import Client 2 | import asyncio 3 | 4 | from app.core.celery_app import celery_app 5 | from app.core.config import settings 6 | 7 | client_sentry = Client(settings.SENTRY_DSN) 8 | 9 | 10 | @celery_app.task(acks_late=True) 11 | async def test_celery(word: str) -> str: 12 | await asyncio.sleep(5) 13 | return f"test task return {word}" 14 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | plugins = pydantic.mypy, sqlmypy 3 | ignore_missing_imports = True 4 | disallow_untyped_defs = True 5 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/prestart.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Let the DB start 4 | python /app/app/backend_pre_start.py 5 | 6 | # Run migrations 7 | alembic upgrade head 8 | 9 | # Create initial data in DB 10 | python /app/app/initial_data.py 11 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "app" 7 | dynamic = ["version"] 8 | description = '' 9 | readme = "README.md" 10 | requires-python = ">=3.11" 11 | license = "MIT" 12 | keywords = [] 13 | authors = [ 14 | { name = "U.N. Owen", email = "{{cookiecutter.first_superuser}}" }, 15 | ] 16 | classifiers = [ 17 | "Development Status :: 4 - Beta", 18 | "Programming Language :: Python", 19 | "Programming Language :: Python :: 3.11", 20 | ] 21 | dependencies = [ 22 | "inboard[fastapi]==0.68.*", 23 | "python-multipart>=0.0.9", 24 | "email-validator>=1.3.0", 25 | "requests>=2.31.0", 26 | "celery>=5.4.0", 27 | "passlib[bcrypt]>=1.7.4", 28 | "tenacity>=8.2.3", 29 | "pydantic>=2.7.1", 30 | "pydantic-settings>=2.2.1", 31 | "emails>=0.6.0", 32 | "raven>=6.10.0", 33 | "jinja2>=3.1.2", 34 | "alembic>=1.13.1", 35 | "sqlalchemy>=2.0.29", 36 | "python-jose[cryptography]>=3.3.0", 37 | "httpx>=0.27.0", 38 | "neo4j>=5.19.0", 39 | "neomodel>=5.3.0", 40 | "psycopg[binary]>=3.1.18", 41 | "setuptools>=69.5.1", 42 | "pytest>=8.2.0", 43 | ] 44 | 45 | [project.optional-dependencies] 46 | checks = [ 47 | "black>=24.4.2", 48 | "mypy>=1.10.0", 49 | "isort>=5.13.2", 50 | "autoflake>=2.3.1", 51 | "flake8>=7.0.0", 52 | ] 53 | 54 | [project.urls] 55 | Documentation = "https://github.com/unknown/app#readme" 56 | Issues = "https://github.com/unknown/app/issues" 57 | Source = "https://github.com/unknown/app" 58 | 59 | [tool.hatch.version] 60 | path = "app/__version__.py" 61 | 62 | [dirs.env] 63 | virtual = "./.venv" 64 | 65 | [tool.hatch.envs.default] 66 | python="3.11" # <-- 67 | dev-mode = true 68 | dependencies = [] 69 | 70 | [tool.hatch.build.targets.sdist] 71 | include = ["/app"] 72 | 73 | [tool.hatch.envs.production] 74 | dev-mode = false 75 | features = [] 76 | path = ".venv" 77 | 78 | [tool.hatch.envs.lint] 79 | detached = true 80 | dependencies = [ 81 | "black>=23.1.0", 82 | "mypy>=1.0.0", 83 | "isort>=5.11.2", 84 | ] 85 | [tool.hatch.envs.lint.scripts] 86 | style = [ 87 | "isort --check --diff {args:.}", 88 | "black --check --diff {args:.}", 89 | ] 90 | fmt = [ 91 | "black {args:.}", 92 | "isort {args:.}", 93 | "style", 94 | ] 95 | all = [ 96 | "style", 97 | "typing", 98 | ] 99 | 100 | [tool.black] 101 | target-version = ["py311"] 102 | line-length = 120 103 | 104 | [tool.isort] 105 | profile = "black" 106 | multi_line_output = 3 107 | include_trailing_comma = true 108 | force_grid_wrap = 0 109 | line_length = 120 110 | src_paths = ["app", "tests"] 111 | 112 | [tool.mypy] 113 | files = ["**/*.py"] 114 | plugins = "pydantic.mypy" 115 | show_error_codes = true 116 | strict = true 117 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/scripts/format-imports.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | set -x 3 | 4 | # Sort imports one per line, so autoflake can remove unused imports 5 | isort --recursive --force-single-line-imports --apply app 6 | sh ./scripts/format.sh 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/scripts/format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | set -x 3 | 4 | autoflake --remove-all-unused-imports --recursive --remove-unused-variables --in-place app --exclude=__init__.py 5 | black app 6 | isort --recursive --apply app 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -x 4 | 5 | mypy app 6 | black app --check 7 | isort --recursive --check-only app 8 | flake8 9 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/scripts/test-cov-html.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | bash scripts/test.sh --cov-report=html "${@}" 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | pytest app/tests "${@}" 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/tests-start.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | set -e 3 | 4 | hatch run python /app/app/tests_pre_start.py 5 | 6 | bash ./scripts/test.sh "$@" 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/worker-start.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | set -e 3 | 4 | hatch run python /app/app/celeryworker_pre_start.py 5 | hatch run celery -A app.worker worker -l info -Q main-queue -c 1 6 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/backend.dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/br3ndonland/inboard:fastapi-0.68-python3.11 2 | 3 | # Use file.name* in case it doesn't exist in the repo 4 | COPY ./app/ /app/ 5 | WORKDIR /app/ 6 | ENV HATCH_ENV_TYPE_VIRTUAL_PATH=.venv 7 | RUN hatch env prune && hatch env create production && pip install --upgrade setuptools 8 | 9 | # /start Project-specific dependencies 10 | # RUN apt-get update && apt-get install -y --no-install-recommends \ 11 | # && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 12 | # WORKDIR /app/ 13 | # /end Project-specific dependencies 14 | 15 | # For development, Jupyter remote kernel 16 | # Using inside the container: 17 | # jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888 18 | ARG INSTALL_JUPYTER=false 19 | RUN bash -c "if [ $INSTALL_JUPYTER == 'true' ] ; then pip install jupyterlab ; fi" 20 | RUN bash -c "pip install argon2_cffi" 21 | 22 | ARG BACKEND_APP_MODULE=app.main:app 23 | ARG BACKEND_PRE_START_PATH=/app/prestart.sh 24 | ARG BACKEND_PROCESS_MANAGER=gunicorn 25 | ARG BACKEND_WITH_RELOAD=false 26 | ENV APP_MODULE=${BACKEND_APP_MODULE} PRE_START_PATH=${BACKEND_PRE_START_PATH} PROCESS_MANAGER=${BACKEND_PROCESS_MANAGER} WITH_RELOAD=${BACKEND_WITH_RELOAD} -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/celeryworker.dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11 2 | WORKDIR /app/ 3 | ARG \ 4 | HATCH_VERSION=1.7.0 \ 5 | PIPX_VERSION=1.2.0 6 | ENV \ 7 | C_FORCE_ROOT=1 \ 8 | HATCH_ENV_TYPE_VIRTUAL_PATH=.venv \ 9 | HATCH_VERSION=$HATCH_VERSION \ 10 | PATH=/opt/pipx/bin:/app/.venv/bin:$PATH \ 11 | PIPX_BIN_DIR=/opt/pipx/bin \ 12 | PIPX_HOME=/opt/pipx/home \ 13 | PIPX_VERSION=$PIPX_VERSION \ 14 | PYTHONPATH=/app 15 | COPY ./app/ /app/ 16 | RUN <(`${apiCore.url()}/service/contact`, 8 | { 9 | method: "POST", 10 | body: data, 11 | } 12 | ) 13 | }, 14 | } -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/app.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/assets/css/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | 3 | html,body, #__nuxt, #__layout{ 4 | height:100%!important; 5 | width: 100%!important; 6 | } 7 | 8 | @tailwind components; 9 | @tailwind utilities; -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/components/alerts/Button.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/components/authentication/MagicLoginCard.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/components/authentication/Navigation.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/components/layouts/Notification.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/components/layouts/default/Navigation.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/components/layouts/home/Navigation.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/components/locale/LocaleDropdown.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 60 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/components/locale/LocaleLink.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/components/moderation/CheckState.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/components/moderation/CheckToggle.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/components/moderation/CreateUser.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/components/moderation/ToggleActive.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/components/moderation/ToggleMod.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/components/moderation/UserTable.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/components/pwa/PwaBadge.client.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/components/pwa/PwaInstallPrompt.client.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/components/pwa/PwaPrompt.client.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/components/settings/ValidateEmailButton.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/config/i18n.ts: -------------------------------------------------------------------------------- 1 | export default defineI18nConfig(() => ({ 2 | // https://phrase.com/blog/posts/nuxt-js-tutorial-i18n/ 3 | // https://v8.i18n.nuxtjs.org/ 4 | // https://saimana.com/list-of-country-locale-code/ 5 | legacy: false, 6 | })) -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/content/blog/20160721-lament-for-the-auther.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: A lament for the author 3 | description: "‘I’ve got a book out,’ says me hopefully, my hands twisted on the keyboard." 4 | author: Gavin Chait 5 | publishedAt: 2016-07-21 6 | categories: publishing 7 | --- 8 | 9 | # A lament for the author 10 | 11 | _21 July 2016, Gavin Chait_ 12 | 13 | ‘I’ve got a book out,’ says me hopefully, my hands twisted on the keyboard. 14 | 15 | Three years ago, I took a month off and set out to start and finish writing a novel. A science fiction novel set in Nigeria, no less. 16 | 17 | The first month allowed me to knock out 60,000 words. It took the next 18 months to make time to expand and polish that core. 18 | 19 | ‘Nobody knows anything,’ said William Goldman in describing Hollywood’s ability to pick winners (and their investors’ regular ability to produce financial disasters). 20 | 21 | I’ve wanted to write novels since before I was in my teens, but it always seemed overly intimidating. Swatting out a few short articles a week is one thing. Sitting down and committing to produce 100,000 words is quite another. 22 | 23 | The not knowing is also about not knowing what’s involved in producing the thing, let alone whether it will be successful. 24 | 25 | And the economics are fairly harsh. If you want to make, for example, R20 from each book, you’re going to have to sell tens of thousands every year before you can quit your day-job. 26 | 27 | In exchange, you need to commit months of time unpaid in the insecure hope that what you produce is – at the very least – read. 28 | 29 | Each day an estimated thousand to two thousand books are published, adding to the 31 million paperbacks or the 3.1 million ebooks already available on Amazon.com. 30 | 31 | Maybe you’ve heard of the self-publishing phenomenon, and of the miraculous stories of people like Hugh Howey who self-published his Wool and Sand series and became a best-seller, or Mark Dawson’s series about an assassin which earns him $450,000 a year? 32 | 33 | Sadly, out of the well over half a million new novels published every year, very few are going to make that sort of money. For most writers, scribbling in any spare time they can manage, they are unlikely to experience that kind of success. 34 | 35 | There are numerous lightning strikes you need to navigate, many lottery tickets which need to be won in sequence before the final lottery of which ‘nobody knows anything’: why does one book become a best-seller but another, similar book, goes read only by close friends and relatives of the author? 36 | 37 | You can throw runes and try divining their meaning; is it price? Is it the cover? How about the day of week or time of day when it is launched? Summer or winter? 38 | 39 | George RR Martin published his first novel in 1983, but it wasn’t till 1996 that he released ‘A Game of Thrones’, and it wasn’t till the fourth in that series – 2005’s ‘A Feast for Crows’ – that he began to achieve success. The HBO ‘Game of Thrones’ adaptation of his novels has made him world famous. 40 | 41 | At the other end is Andy Weir who published his first novel, ‘The Martian’, in 2011, achieved runaway success immediately, and saw it turned into a madly successful movie in 2015. 42 | 43 | Writers can achieve success instantly, languish in obscurity and then achieve success, or languish in obscurity indefinitely. 44 | 45 | Figuring out what and who will connect is, well, you know already. 46 | 47 | Weirdly, the same is true of newspaper columns I’ve written. I’ve had relatively obscure topics explode my inbox, and others where I thought it would result in some controversy result in the gentle sound of crickets at midnight. 48 | 49 | Despite all the uncertainty - and the supposed destruction of mainstream publishers - 60% of all commercial sales still accrue to the big six publishers. In the US, that’s an astonishing $27 billion industry total a year. 50 | 51 | I hope you’re interested in reading about how my hero escapes from an orbital prison, survives the subsequent fall and crash-landing in a small Nigerian village, and escapes the interest of a local warlord. 52 | 53 | Continues me, ‘It’ll be out on Friday. Like a real book, with pages and everything. It’s called [“Lament for the Fallen”](https://gavinchait.com/lament-for-the-fallen/), go buy it.’ 54 | 55 | --- 56 | 57 | _:copyright: Gavin Chait 2016. All rights reserved._ 58 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/content/privacy.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Your privacy 3 | description: "We at [Website Name] value your privacy as much as we value a cheap joke." 4 | navigation: false 5 | --- 6 | 7 | # Your privacy and rights 8 | 9 | Welcome to [Website Name], the online version of a hallucination induced by eating too much cheese. Here's what you can expect when you use our website: 10 | 11 | ## Privacy policy 12 | 13 | - We'll collect every piece of personal information you have, including your name, address, phone number, email, social security number, credit card details, and your mother's maiden name. We'll also peek into your browser history, your text messages, and your dreams (if we can find the right mushrooms). 14 | - We promise to use your data for twisted and bizarre purposes only, like cloning you and making you fight your clone to the death, using your DNA to create a race of superhumans, or summoning a demon that looks like your grandma. 15 | - We'll use your information to spam you with ads that are so surreal and disorienting, you'll think you're trapped in a Salvador Dali painting. We'll also use it to mess with your mind, make you question reality, and possibly even inspire you to start a cult (which we'll join, of course). 16 | - We'll store your data in a realm of pure chaos and madness, guarded by an army of chimeras, goblins, and robots that have gone rogue. We'll also share your data with our interdimensional overlords, who are always hungry for new sources of entertainment. 17 | - We'll use cookies to track your every move online, and we'll use that information to create a digital avatar of you that's even weirder and more unpredictable than the real you. We'll also use cookies to play pranks on you, like making your cursor turn into a banana or making your keyboard explode (don't worry, it's just a harmless little explosion). 18 | 19 | ## GDPR 20 | 21 | We don't care about GDPR or any other earthly laws. Our website operates in a dimension beyond your feeble human concepts of order and justice. If you try to sue us, we'll just laugh and summon a horde of poltergeists to haunt you for eternity. 22 | 23 | ## Liability 24 | 25 | By using our website, you agree to relinquish all control over your sanity, your identity, and your soul. You acknowledge that our website is a portal to a universe of madness and mayhem, and that you are entering at your own risk. We're not liable for any psychological, spiritual, or metaphysical damage that may result from using our website. But hey, at least you'll have a good story to tell the angels (or the demons, depending on how things turn out). 26 | 27 | Thank you for choosing [Website Name], where the rules of logic and reality are optional, and the nightmares are free of charge. 28 | 29 | _(Yes, generated by ChatGPT. Replace this with something meaningful.) -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | IUserProfile, 3 | IUserProfileUpdate, 4 | IUserProfileCreate, 5 | IUserOpenProfileCreate 6 | } from "./profile" 7 | 8 | import type { 9 | ITokenResponse, 10 | IWebToken, 11 | INewTOTP, 12 | IEnableTOTP, 13 | ISendEmail, 14 | IMsg, 15 | INotification 16 | } from "./utilities" 17 | 18 | // https://stackoverflow.com/a/64782482/295606 19 | interface IKeyable { 20 | [key: string]: any | any[] 21 | } 22 | 23 | export type { 24 | IKeyable, 25 | IUserProfile, 26 | IUserProfileUpdate, 27 | IUserProfileCreate, 28 | IUserOpenProfileCreate, 29 | ITokenResponse, 30 | IWebToken, 31 | INewTOTP, 32 | IEnableTOTP, 33 | ISendEmail, 34 | IMsg, 35 | INotification 36 | } -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/interfaces/profile.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | export interface IUserProfile { 3 | id: string 4 | email: string 5 | email_validated: boolean 6 | is_active: boolean 7 | is_superuser: boolean 8 | full_name: string 9 | password: boolean 10 | totp: boolean 11 | } 12 | 13 | export interface IUserProfileUpdate { 14 | email?: string 15 | full_name?: string 16 | original?: string 17 | password?: string 18 | is_active?: boolean 19 | is_superuser?: boolean 20 | } 21 | 22 | export interface IUserProfileCreate { 23 | email: string 24 | full_name?: string 25 | password?: string 26 | is_active?: boolean 27 | is_superuser?: boolean 28 | } 29 | 30 | export interface IUserOpenProfileCreate { 31 | email: string 32 | full_name?: string 33 | password: string 34 | } 35 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/interfaces/utilities.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | 3 | export interface ITokenResponse { 4 | access_token: string 5 | refresh_token: string 6 | token_type: string 7 | } 8 | 9 | export interface IWebToken { 10 | claim: string 11 | } 12 | 13 | export interface INewTOTP { 14 | secret?: string 15 | key: string 16 | uri: string 17 | } 18 | 19 | 20 | export interface IEnableTOTP { 21 | claim: string 22 | uri: string 23 | password?: string 24 | } 25 | 26 | export interface ISendEmail { 27 | email: string 28 | subject: string 29 | content: string 30 | } 31 | 32 | export interface IMsg { 33 | msg: string 34 | } 35 | 36 | export interface INotification { 37 | uid?: string 38 | title: string 39 | content: string 40 | icon?: "success" | "error" | "information" 41 | showProgress?: boolean 42 | } -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/layouts/authentication.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/layouts/content.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/layouts/home.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/locales/en-GB.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | common: { 3 | title: "App", 4 | }, 5 | nav: { 6 | about: "About", 7 | authentication: "Authentication", 8 | blog: "Blog", 9 | }, 10 | footer: { 11 | rights: "All rights reserved." 12 | }, 13 | pwa: { 14 | dismiss: "Dismiss", 15 | install: "Install", 16 | install_title: "Install Base App", 17 | title: "New Base App update available!", 18 | update: "Update", 19 | update_available_short: "Update Base App", 20 | }, 21 | } -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/locales/fr-FR.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | common: { 3 | title: "App", 4 | }, 5 | nav: { 6 | about: "À propos", 7 | authentication: "Authentication", 8 | blog: "Blog", 9 | }, 10 | footer: { 11 | rights: "Tous droits réservés." 12 | }, 13 | } -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/middleware/anonymous.ts: -------------------------------------------------------------------------------- 1 | import { useAuthStore } from "@/stores" 2 | 3 | export default defineNuxtRouteMiddleware((to, from) => { 4 | const routes = ["/login", "/join", "/recover-password", "/reset-password"] 5 | const authStore = useAuthStore() 6 | if (authStore.loggedIn) { 7 | if (routes.includes(from.path)) return navigateTo("/") 8 | else return abortNavigation() 9 | } 10 | }) -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/middleware/authenticated.ts: -------------------------------------------------------------------------------- 1 | import { useAuthStore } from "@/stores" 2 | 3 | export default defineNuxtRouteMiddleware((to, from) => { 4 | const authStore = useAuthStore() 5 | const routes = ["/login", "/join", "/recover-password", "/reset-password"] 6 | if (!authStore.loggedIn) { 7 | if (routes.includes(from.path)) return navigateTo("/") 8 | else return abortNavigation() 9 | } 10 | }) -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/middleware/moderator.ts: -------------------------------------------------------------------------------- 1 | import { useAuthStore } from "@/stores" 2 | 3 | export default defineNuxtRouteMiddleware((to, from) => { 4 | const authStore = useAuthStore() 5 | if (!authStore.isAdmin) { 6 | return abortNavigation() 7 | } 8 | }) -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/middleware/refresh.ts: -------------------------------------------------------------------------------- 1 | import { useAuthStore } from "@/stores" 2 | 3 | export default defineNuxtRouteMiddleware(async (to, from) => { 4 | const authStore = useAuthStore() 5 | if (!authStore.loggedIn) { 6 | await authStore.getUserProfile() 7 | } 8 | }) -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "nuxt build", 5 | "dev": "nuxt dev", 6 | "generate": "nuxt generate", 7 | "start": "nuxt start", 8 | "preview": "nuxt preview" 9 | }, 10 | "devDependencies": { 11 | "@nuxt/content": "^2.12.1", 12 | "@nuxtjs/i18n": "^8.3.1", 13 | "@pinia-plugin-persistedstate/nuxt": "^1.2.0", 14 | "@tailwindcss/aspect-ratio": "^0.4.2", 15 | "@tailwindcss/forms": "^0.5.7", 16 | "@tailwindcss/typography": "^0.5.13", 17 | "@vite-pwa/assets-generator": "^0.2.4", 18 | "@vite-pwa/nuxt": "^0.7.0", 19 | "autoprefixer": "^10.4.19", 20 | "nuxt": "^3.11.2", 21 | "postcss": "^8.4.38", 22 | "tailwindcss": "^3.4.3" 23 | }, 24 | "dependencies": { 25 | "@headlessui/vue": "^1.7.21", 26 | "@heroicons/vue": "2.1.3", 27 | "@nuxtjs/robots": "^3.0.0", 28 | "@pinia/nuxt": "^0.5.1", 29 | "@vee-validate/i18n": "^4.12.6", 30 | "@vee-validate/rules": "^4.12.6", 31 | "pinia": "^2.1.7", 32 | "qrcode.vue": "^3.3.3", 33 | "vee-validate": "^4.12.6" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/pages/[...slug].vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 22 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/pages/blog/[...slug].vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/pages/blog/index.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/pages/magic.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/pages/moderation.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/pages/recover-password.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/pages/reset-password.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/pages/settings.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/pages/totp.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/plugins/veevalidate-components.ts: -------------------------------------------------------------------------------- 1 | import { Form, Field, ErrorMessage } from "vee-validate"; 2 | 3 | export default defineNuxtPlugin((nuxtApp) => { 4 | nuxtApp.vueApp.component("Form", Form); 5 | nuxtApp.vueApp.component("Field", Field); 6 | nuxtApp.vueApp.component("ErrorMessage", ErrorMessage); 7 | }); -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/plugins/veevalidate-rules.ts: -------------------------------------------------------------------------------- 1 | import { defineRule, configure } from "vee-validate"; 2 | import { required, email, min, max, url } from "@vee-validate/rules"; 3 | import { localize } from "@vee-validate/i18n"; 4 | 5 | export default defineNuxtPlugin((nuxtApp) => { 6 | defineRule("required", required); 7 | defineRule("email", email); 8 | defineRule("min", min); 9 | defineRule("max", max); 10 | defineRule("url", url); 11 | // @ts-ignore 12 | defineRule("confirmed", (value, [target], ctx) => { 13 | // https://vee-validate.logaretm.com/v4/guide/global-validators#cross-field-validation 14 | if (value === ctx.form[target]) { 15 | return true; 16 | } 17 | return "Passwords must match."; 18 | }); 19 | }); 20 | 21 | configure({ 22 | // Generates an English message locale generator 23 | generateMessage: localize("en", { 24 | messages: { 25 | required: "This field is required.", 26 | email: "This email address is invalid.", 27 | min: "Passwords must be 8 to 64 characters long.", 28 | max: "Passwords must be 8 to 64 characters long.", 29 | url: "This url is invalid.", 30 | }, 31 | }), 32 | }); 33 | 34 | /* 35 | References: 36 | 37 | https://vee-validate.logaretm.com/v4/guide/overview/ 38 | https://github.com/razorcx-courses/nuxt3-veevalidate 39 | https://vee-validate.logaretm.com/v4/guide/global-validators/#available-rules 40 | https://vee-validate.logaretm.com/v4/guide/i18n 41 | */ -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/images/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/{{cookiecutter.project_slug}}/frontend/public/images/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/{{cookiecutter.project_slug}}/frontend/public/images/favicon.ico -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/images/mark.svg: -------------------------------------------------------------------------------- 1 | 2 | 16 | 18 | 19 | 21 | image/svg+xml 22 | 24 | 25 | 26 | 27 | 47 | 52 | 54 | 61 | 64 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/images/maskable-icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/{{cookiecutter.project_slug}}/frontend/public/images/maskable-icon-512x512.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/images/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/{{cookiecutter.project_slug}}/frontend/public/images/pwa-192x192.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/images/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/{{cookiecutter.project_slug}}/frontend/public/images/pwa-512x512.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/images/pwa-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whythawk/full-stack-fastapi-postgresql/9f9b02582e7106394643ee7d3dbdd8fbe6e5f7a6/{{cookiecutter.project_slug}}/frontend/public/images/pwa-64x64.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/stores/index.ts: -------------------------------------------------------------------------------- 1 | import { useAuthStore } from "./auth" 2 | import { useTokenStore } from "./tokens" 3 | import { useToastStore } from "./toasts" 4 | 5 | export { 6 | useAuthStore, useTokenStore, useToastStore 7 | } -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/stores/toasts.ts: -------------------------------------------------------------------------------- 1 | import type { INotification } from "@/interfaces" 2 | import { generateUUID } from "@/utilities" 3 | 4 | export const useToastStore = defineStore("toasts", { 5 | state: () => ({ 6 | notifications: [] as INotification[] 7 | }), 8 | getters: { 9 | first: (state) => state.notifications.length > 0 && state.notifications[0], 10 | notices: (state) => state.notifications 11 | }, 12 | actions: { 13 | addNotice (payload: INotification) { 14 | payload.uid = generateUUID() 15 | if (!payload.icon) payload.icon = "success" 16 | this.notices.push(payload) 17 | }, 18 | removeNotice (payload: INotification) { 19 | this.notifications = this.notices.filter( 20 | (note) => note !== payload 21 | ) 22 | }, 23 | async timeoutNotice (payload: INotification, timeout: number = 2000) { 24 | await new Promise((resolve) => { 25 | setTimeout(() => { 26 | this.removeNotice(payload) 27 | resolve(true) 28 | }, timeout) 29 | }) 30 | }, 31 | // reset state using `$reset` 32 | deleteNotices() { 33 | this.$reset() 34 | } 35 | } 36 | }) -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import("tailwindcss").Config} */ 2 | const colors = require("tailwindcss/colors") 3 | 4 | module.exports = { 5 | content: [ 6 | "./components/**/*.{js,vue,ts}", 7 | "./layouts/**/*.vue", 8 | "./pages/**/*.vue", 9 | "./plugins/**/*.{js,ts}", 10 | "./nuxt.config.{js,ts}", 11 | ], 12 | theme: { 13 | extend: { 14 | colors: { 15 | teal: colors.teal, 16 | cyan: colors.cyan, 17 | rose: colors.rose, 18 | }, 19 | }, 20 | }, 21 | corePlugins: { 22 | aspectRatio: false, 23 | }, 24 | plugins: [ 25 | require("@tailwindcss/typography"), 26 | require("@tailwindcss/forms"), 27 | require("@tailwindcss/aspect-ratio"), 28 | ], 29 | } 30 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://v3.nuxtjs.org/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/utilities/generic.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from "buffer" 2 | 3 | function generateUUID(): string { 4 | // Reference: https://stackoverflow.com/a/2117523/709884 5 | // And: https://stackoverflow.com/a/61011303/295606 6 | return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (s) => { 7 | const c = Number.parseInt(s, 10) 8 | return ( 9 | c ^ 10 | (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4))) 11 | ).toString(16) 12 | }) 13 | } 14 | 15 | function isValidHttpUrl(urlString: string) { 16 | // https://stackoverflow.com/a/43467144 17 | let url 18 | try { 19 | url = new URL(urlString) 20 | } catch (_) { 21 | return false 22 | } 23 | return url.protocol === "http:" || url.protocol === "https:" 24 | } 25 | 26 | function getKeyByValue(object: any, value: any) { 27 | // https://stackoverflow.com/a/28191966/295606 28 | return Object.keys(object).find((key) => object[key] === value) 29 | } 30 | 31 | function getTimeInSeconds(): number { 32 | // https://stackoverflow.com/a/3830279/295606 33 | return Math.floor(new Date().getTime() / 1000) 34 | } 35 | 36 | function tokenExpired(token: string) { 37 | // https://stackoverflow.com/a/60758392/295606 38 | // https://stackoverflow.com/a/71953677/295606 39 | const expiry = JSON.parse( 40 | Buffer.from(token.split(".")[1], "base64").toString() 41 | ).exp 42 | return getTimeInSeconds() >= expiry 43 | } 44 | 45 | function tokenParser(token: string) { 46 | return JSON.parse( 47 | Buffer.from(token.split(".")[1], "base64").toString() 48 | ) 49 | } 50 | 51 | export { 52 | generateUUID, 53 | getTimeInSeconds, 54 | tokenExpired, 55 | getKeyByValue, 56 | isValidHttpUrl, 57 | tokenParser, 58 | } 59 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/utilities/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | generateUUID, 3 | getTimeInSeconds, 4 | tokenExpired, 5 | getKeyByValue, 6 | isValidHttpUrl, 7 | tokenParser, 8 | } from "./generic" 9 | import { 10 | readableDate, 11 | } from "./textual" 12 | import { 13 | tokenIsTOTP 14 | } from "./totp" 15 | 16 | export { 17 | generateUUID, 18 | getTimeInSeconds, 19 | tokenExpired, 20 | getKeyByValue, 21 | isValidHttpUrl, 22 | tokenParser, 23 | readableDate, 24 | tokenIsTOTP, 25 | } -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/utilities/textual.ts: -------------------------------------------------------------------------------- 1 | function readableDate(term: Date | string, showYear: boolean = true) { 2 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleDateString 3 | // https://stackoverflow.com/a/66590756/295606 4 | // https://stackoverflow.com/a/67196206/295606 5 | const readable = term instanceof Date ? term : new Date(term) 6 | const day = readable.toLocaleDateString("en-UK", { day: "numeric" }) 7 | const month = readable.toLocaleDateString("en-UK", { month: "short" }) 8 | if (showYear) { 9 | const year = readable.toLocaleDateString("en-UK", { year: "numeric" }) 10 | return `${day} ${month} ${year}` 11 | } 12 | return `${day} ${month}` 13 | } 14 | 15 | export { 16 | readableDate, 17 | } -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/utilities/totp.ts: -------------------------------------------------------------------------------- 1 | import { tokenParser } from "./generic" 2 | 3 | 4 | function tokenIsTOTP(token: string) { 5 | // https://stackoverflow.com/a/60758392/295606 6 | // https://stackoverflow.com/a/71953677/295606 7 | const obj = tokenParser(token) 8 | if (obj.hasOwnProperty("totp")) return obj.totp 9 | else return false 10 | } 11 | 12 | export { 13 | tokenIsTOTP 14 | } -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/scripts/build-push.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | TAG=${TAG?Variable not set} \ 7 | FRONTEND_ENV=${FRONTEND_ENV-production} \ 8 | sh ./scripts/build.sh 9 | 10 | docker-compose -f docker-compose.yml push 11 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/scripts/build.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | TAG=${TAG?Variable not set} \ 7 | FRONTEND_ENV=${FRONTEND_ENV-production} \ 8 | docker-compose \ 9 | -f docker-compose.yml \ 10 | build 11 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | DOMAIN=${DOMAIN?Variable not set} \ 7 | TRAEFIK_TAG=${TRAEFIK_TAG?Variable not set} \ 8 | STACK_NAME=${STACK_NAME?Variable not set} \ 9 | TAG=${TAG?Variable not set} \ 10 | docker-compose \ 11 | -f docker-compose.yml \ 12 | config > docker-stack.yml 13 | 14 | docker-auto-labels docker-stack.yml 15 | 16 | docker stack deploy -c docker-stack.yml --with-registry-auth "${STACK_NAME?Variable not set}" 17 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/scripts/test-local.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | docker-compose down -v --remove-orphans # Remove possibly previous broken stacks left hanging after an error 7 | 8 | if [ $(uname -s) = "Linux" ]; then 9 | echo "Remove __pycache__ files" 10 | sudo find . -type d -name __pycache__ -exec rm -r {} \+ 11 | fi 12 | 13 | docker-compose build 14 | docker-compose up -d 15 | docker-compose exec -T backend bash /app/tests-start.sh "$@" 16 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/scripts/test.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | DOMAIN=backend \ 7 | SMTP_HOST="" \ 8 | TRAEFIK_PUBLIC_NETWORK_IS_EXTERNAL=false \ 9 | INSTALL_DEV=true \ 10 | docker-compose \ 11 | -f docker-compose.yml \ 12 | config > docker-stack.yml 13 | 14 | docker-compose -f docker-stack.yml build 15 | docker-compose -f docker-stack.yml down -v --remove-orphans # Remove possibly previous broken stacks left hanging after an error 16 | docker-compose -f docker-stack.yml up -d 17 | docker-compose -f docker-stack.yml exec -T backend bash /app/tests-start.sh "$@" 18 | docker-compose -f docker-stack.yml down -v --remove-orphans 19 | --------------------------------------------------------------------------------