├── .github └── workflows │ └── main.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── cookiecutter.json ├── dev-fsfcb-back.sh ├── dev-fsfcb-config.yml ├── dev-fsfcb.sh ├── hooks └── post_gen_project.py ├── screenshot.png ├── scripts ├── discard-dev-files.sh └── generate_cookiecutter_config.py ├── test.sh ├── testing-config.yml └── {{cookiecutter.project_slug}} ├── .env ├── .gitignore ├── .gitlab-ci.yml ├── README.md ├── backend ├── .gitignore ├── app │ ├── Pipfile │ ├── app │ │ ├── __init__.py │ │ ├── api │ │ │ ├── __init__.py │ │ │ ├── api_v1 │ │ │ │ ├── __init__.py │ │ │ │ ├── api.py │ │ │ │ └── endpoints │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── items.py │ │ │ │ │ ├── login.py │ │ │ │ │ ├── roles.py │ │ │ │ │ ├── users.py │ │ │ │ │ └── utils.py │ │ │ └── utils │ │ │ │ ├── __init__.py │ │ │ │ └── security.py │ │ ├── backend_pre_start.py │ │ ├── celeryworker_pre_start.py │ │ ├── core │ │ │ ├── __init__.py │ │ │ ├── celery_app.py │ │ │ ├── config.py │ │ │ ├── jwt.py │ │ │ └── security.py │ │ ├── crud │ │ │ ├── __init__.py │ │ │ ├── item.py │ │ │ ├── user.py │ │ │ └── utils.py │ │ ├── db │ │ │ ├── __init__.py │ │ │ ├── couchbase_utils.py │ │ │ ├── database.py │ │ │ ├── full_text_search_utils.py │ │ │ └── init_db.py │ │ ├── email-templates │ │ │ ├── build │ │ │ │ ├── new_account.html │ │ │ │ ├── reset_password.html │ │ │ │ └── test_email.html │ │ │ └── src │ │ │ │ ├── new_account.mjml │ │ │ │ ├── reset_password.mjml │ │ │ │ └── test_email.mjml │ │ ├── main.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── config.py │ │ │ ├── item.py │ │ │ ├── msg.py │ │ │ ├── role.py │ │ │ ├── token.py │ │ │ └── user.py │ │ ├── search_index_definitions │ │ │ ├── items.json │ │ │ ├── items_01.json │ │ │ ├── users.json │ │ │ └── users_01.json │ │ ├── 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_get_ids.py │ │ │ │ ├── test_item.py │ │ │ │ └── test_user.py │ │ │ └── utils │ │ │ │ ├── __init__.py │ │ │ │ ├── item.py │ │ │ │ ├── user.py │ │ │ │ └── utils.py │ │ ├── tests_pre_start.py │ │ ├── utils.py │ │ └── worker.py │ ├── prestart.sh │ ├── scripts │ │ └── lint.sh │ ├── tests-start.sh │ └── worker-start.sh ├── backend.dockerfile ├── celeryworker.dockerfile └── tests.dockerfile ├── cookiecutter-config-file.yml ├── docker-compose.deploy.build.yml ├── docker-compose.deploy.command.yml ├── docker-compose.deploy.images.yml ├── docker-compose.deploy.labels.yml ├── docker-compose.deploy.networks.yml ├── docker-compose.deploy.volumes-placement.yml ├── docker-compose.dev.build.yml ├── docker-compose.dev.command.yml ├── docker-compose.dev.env.yml ├── docker-compose.dev.labels.yml ├── docker-compose.dev.networks.yml ├── docker-compose.dev.ports.yml ├── docker-compose.dev.volumes.yml ├── docker-compose.shared.admin.yml ├── docker-compose.shared.base-images.yml ├── docker-compose.shared.depends.yml ├── docker-compose.shared.env.yml ├── docker-compose.test.yml ├── env-backend.env ├── env-couchbase.env ├── env-flower.env ├── env-sync-gateway.env ├── frontend ├── .dockerignore ├── .env ├── .gitignore ├── Dockerfile ├── README.md ├── babel.config.js ├── nginx-backend-not-found.conf ├── package.json ├── public │ ├── favicon.ico │ ├── img │ │ └── icons │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon-120x120.png │ │ │ ├── apple-touch-icon-152x152.png │ │ │ ├── apple-touch-icon-180x180.png │ │ │ ├── apple-touch-icon-60x60.png │ │ │ ├── apple-touch-icon-76x76.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── msapplication-icon-144x144.png │ │ │ ├── mstile-150x150.png │ │ │ └── safari-pinned-tab.svg │ ├── index.html │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.vue │ ├── api.ts │ ├── assets │ │ └── logo.png │ ├── component-hooks.ts │ ├── components │ │ ├── NotificationsManager.vue │ │ ├── RouterComponent.vue │ │ └── UploadButton.vue │ ├── env.ts │ ├── interfaces │ │ └── index.ts │ ├── main.ts │ ├── plugins │ │ ├── vee-validate.ts │ │ └── vuetify.ts │ ├── registerServiceWorker.ts │ ├── router.ts │ ├── shims-tsx.d.ts │ ├── shims-vue.d.ts │ ├── store │ │ ├── admin │ │ │ ├── actions.ts │ │ │ ├── getters.ts │ │ │ ├── index.ts │ │ │ ├── mutations.ts │ │ │ └── state.ts │ │ ├── index.ts │ │ ├── main │ │ │ ├── actions.ts │ │ │ ├── getters.ts │ │ │ ├── index.ts │ │ │ ├── mutations.ts │ │ │ └── state.ts │ │ └── state.ts │ ├── utils.ts │ └── views │ │ ├── Login.vue │ │ ├── PasswordRecovery.vue │ │ ├── ResetPassword.vue │ │ └── main │ │ ├── Dashboard.vue │ │ ├── Main.vue │ │ ├── Start.vue │ │ ├── admin │ │ ├── Admin.vue │ │ ├── AdminUsers.vue │ │ ├── CreateUser.vue │ │ └── EditUser.vue │ │ └── profile │ │ ├── UserProfile.vue │ │ ├── UserProfileEdit.vue │ │ └── UserProfileEditPassword.vue ├── tests │ └── unit │ │ └── upload-button.spec.ts ├── tsconfig.json ├── tslint.json └── vue.config.js ├── scripts ├── build-push.sh ├── build.sh ├── deploy.sh ├── test-local.sh └── test.sh └── sync-gateway ├── Dockerfile ├── create_config.py ├── entrypoint.sh └── sync └── sync-function.js /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | on: 2 | schedule: 3 | - cron: "0 0 * * *" 4 | 5 | jobs: 6 | issue-manager: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: tiangolo/issue-manager@master 10 | with: 11 | token: ${{ secrets.GITHUB_TOKEN }} 12 | config: > 13 | { 14 | "answered": { 15 | "users": ["tiangolo"], 16 | "delay": 864000, 17 | "message": "Assuming the original issue was solved, it will be automatically closed now. But feel free to add more comments or create new issues." 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | testing-project 3 | .mypy_cache 4 | -------------------------------------------------------------------------------- /.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 ./test.sh 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Sebastián Ramírez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_name": "Base Project", 3 | "project_slug": "{{ cookiecutter.project_name|lower|replace(' ', '-') }}", 4 | "domain_main": "{{cookiecutter.project_slug}}.com", 5 | "domain_staging": "stag.{{cookiecutter.domain_main}}", 6 | 7 | "docker_swarm_stack_name_main": "{{cookiecutter.domain_main|replace('.', '-')}}", 8 | "docker_swarm_stack_name_staging": "{{cookiecutter.domain_staging|replace('.', '-')}}", 9 | 10 | "secret_key": "changethis", 11 | "first_superuser": "admin@{{cookiecutter.domain_main}}", 12 | "first_superuser_password": "changethis", 13 | "backend_cors_origins": "http://localhost, http://localhost:4200, http://localhost:3000, http://localhost:8080, https://localhost, https://localhost:4200, https://localhost:3000, https://localhost:8080, http://dev.{{cookiecutter.domain_main}}, https://{{cookiecutter.domain_staging}}, https://{{cookiecutter.domain_main}}, http://local.dockertoolbox.tiangolo.com, http://localhost.tiangolo.com", 14 | "smtp_port": "587", 15 | "smtp_host": "", 16 | "smtp_user": "", 17 | "smtp_password": "", 18 | "smtp_emails_from_email": "info@{{cookiecutter.domain_main}}", 19 | 20 | "couchbase_user": "admin", 21 | "couchbase_password": "changethis", 22 | 23 | "couchbase_sync_gateway_cors": "http://localhost:4984, http://localhost:4985, http://localhost, http://localhost:4200, http://localhost:3000, http://localhost:8080, http://dev.{{cookiecutter.domain_main}}, https://{{cookiecutter.domain_staging}}, https://db.{{cookiecutter.domain_staging}}, https://{{cookiecutter.domain_main}}, https://db.{{cookiecutter.domain_main}}, http://local.dockertoolbox.tiangolo.com, http://local.dockertoolbox.tiangolo.com:4984, http://localhost.tiangolo.com, http://localhost.tiangolo.com:4984", 24 | "couchbase_sync_gateway_user": "sync", 25 | "couchbase_sync_gateway_password": "changethis", 26 | 27 | "traefik_constraint_tag": "{{cookiecutter.domain_main}}", 28 | "traefik_constraint_tag_staging": "{{cookiecutter.domain_staging}}", 29 | "traefik_public_network": "traefik-public", 30 | "traefik_public_constraint_tag": "traefik-public", 31 | 32 | "flower_auth": "admin:{{cookiecutter.first_superuser_password}}", 33 | 34 | "sentry_dsn": "", 35 | 36 | "docker_image_prefix": "", 37 | 38 | "docker_image_backend": "{{cookiecutter.docker_image_prefix}}backend", 39 | "docker_image_celeryworker": "{{cookiecutter.docker_image_prefix}}celeryworker", 40 | "docker_image_frontend": "{{cookiecutter.docker_image_prefix}}frontend", 41 | "docker_image_sync_gateway": "{{cookiecutter.docker_image_prefix}}sync-gateway", 42 | 43 | "_copy_without_render": [ 44 | "frontend/src/**/*.html", 45 | "frontend/src/**/*.vue", 46 | "frontend/node_modules/*", 47 | "backend/app/app/email-templates/**" 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /dev-fsfcb-back.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Run this script from outside the project, to integrate a dev-fsfcb project with changes and review modifications 4 | 5 | # Exit in case of error 6 | set -e 7 | 8 | if [ $(uname -s) = "Linux" ]; then 9 | echo "Remove __pycache__ files" 10 | sudo find ./dev-fsfcb/ -type d -name __pycache__ -exec rm -r {} \+ 11 | fi 12 | 13 | rm -rf ./full-stack-fastapi-couchbase/\{\{cookiecutter.project_slug\}\}/* 14 | 15 | rsync -a --exclude=node_modules ./dev-fsfcb/* ./full-stack-fastapi-couchbase/\{\{cookiecutter.project_slug\}\}/ 16 | 17 | rsync -a ./dev-fsfcb/{.env,.gitignore,.gitlab-ci.yml} ./full-stack-fastapi-couchbase/\{\{cookiecutter.project_slug\}\}/ 18 | -------------------------------------------------------------------------------- /dev-fsfcb-config.yml: -------------------------------------------------------------------------------- 1 | default_context: 2 | "project_name": "Dev FSFCB" 3 | -------------------------------------------------------------------------------- /dev-fsfcb.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Run this script from outside the project, to generate a dev-fsfcb project 4 | 5 | # Exit in case of error 6 | set -e 7 | 8 | rm -rf ./dev-fsfcb 9 | 10 | cookiecutter --config-file ./full-stack-fastapi-couchbase/dev-fsfcb-config.yml --no-input -f ./full-stack-fastapi-couchbase 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/screenshot.png -------------------------------------------------------------------------------- /scripts/discard-dev-files.sh: -------------------------------------------------------------------------------- 1 | rm -rf \{\{cookiecutter.project_slug\}\}/.git 2 | rm -rf \{\{cookiecutter.project_slug\}\}/frontend/node_modules 3 | rm -rf \{\{cookiecutter.project_slug\}\}/frontend/dist 4 | git checkout \{\{cookiecutter.project_slug\}\}/README.md 5 | git checkout \{\{cookiecutter.project_slug\}\}/.gitlab-ci.yml 6 | git checkout \{\{cookiecutter.project_slug\}\}/cookiecutter-config-file.yml 7 | git checkout \{\{cookiecutter.project_slug\}\}/docker-compose.deploy.networks.yml 8 | git checkout \{\{cookiecutter.project_slug\}\}/env-backend.env 9 | git checkout \{\{cookiecutter.project_slug\}\}/env-couchbase.env 10 | git checkout \{\{cookiecutter.project_slug\}\}/env-flower.env 11 | git checkout \{\{cookiecutter.project_slug\}\}/.env 12 | git checkout \{\{cookiecutter.project_slug\}\}/frontend/.env 13 | git checkout \{\{cookiecutter.project_slug\}\}/env-sync-gateway.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 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | rm -rf ./testing-project 7 | 8 | cookiecutter --config-file ./testing-config.yml --no-input -f ./ 9 | 10 | cd ./testing-project 11 | 12 | bash ./scripts/test.sh 13 | 14 | cd ../ 15 | -------------------------------------------------------------------------------- /testing-config.yml: -------------------------------------------------------------------------------- 1 | default_context: 2 | "project_name": "Testing Project" 3 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/.env: -------------------------------------------------------------------------------- 1 | COMPOSE_PATH_SEPARATOR=: 2 | COMPOSE_FILE=docker-compose.test.yml:docker-compose.shared.admin.yml:docker-compose.shared.base-images.yml:docker-compose.shared.depends.yml:docker-compose.shared.env.yml:docker-compose.dev.build.yml:docker-compose.dev.command.yml:docker-compose.dev.env.yml:docker-compose.dev.labels.yml:docker-compose.dev.networks.yml:docker-compose.dev.ports.yml:docker-compose.dev.volumes.yml 3 | 4 | DOMAIN=localhost 5 | # DOMAIN=local.dockertoolbox.tiangolo.com 6 | # DOMAIN=localhost.tiangolo.com 7 | # DOMAIN=dev.{{cookiecutter.domain_main}} 8 | 9 | TRAEFIK_TAG={{cookiecutter.traefik_constraint_tag}} 10 | TRAEFIK_PUBLIC_NETWORK={{cookiecutter.traefik_public_network}} 11 | TRAEFIK_PUBLIC_TAG={{cookiecutter.traefik_public_constraint_tag}} 12 | 13 | DOCKER_IMAGE_BACKEND={{cookiecutter.docker_image_backend}} 14 | DOCKER_IMAGE_CELERYWORKER={{cookiecutter.docker_image_celeryworker}} 15 | DOCKER_IMAGE_FRONTEND={{cookiecutter.docker_image_frontend}} 16 | DOCKER_IMAGE_SYNC_GATEWAY={{cookiecutter.docker_image_sync_gateway}} 17 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .mypy_cache 3 | docker-stack.yml 4 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: tiangolo/docker-with-compose 2 | 3 | before_script: 4 | - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY 5 | - pip install docker-auto-labels 6 | 7 | stages: 8 | - test 9 | - build 10 | - deploy 11 | 12 | tests: 13 | stage: test 14 | script: 15 | - sh ./scripts/test.sh 16 | tags: 17 | - build 18 | - test 19 | 20 | build-stag: 21 | stage: build 22 | script: 23 | - TAG=stag FRONTEND_ENV=staging sh ./scripts/build-push.sh 24 | only: 25 | - master 26 | tags: 27 | - build 28 | - test 29 | 30 | build-prod: 31 | stage: build 32 | script: 33 | - TAG=prod FRONTEND_ENV=production sh ./scripts/build-push.sh 34 | only: 35 | - production 36 | tags: 37 | - build 38 | - test 39 | 40 | deploy-stag: 41 | stage: deploy 42 | script: 43 | - > 44 | DOMAIN={{cookiecutter.domain_staging}} 45 | TRAEFIK_TAG={{cookiecutter.traefik_constraint_tag_staging}} 46 | STACK_NAME={{cookiecutter.docker_swarm_stack_name_staging}} 47 | TAG=stag 48 | sh ./scripts/deploy.sh 49 | environment: 50 | name: staging 51 | url: https://{{cookiecutter.domain_staging}} 52 | only: 53 | - master 54 | tags: 55 | - swarm 56 | - stag 57 | 58 | deploy-prod: 59 | stage: deploy 60 | script: 61 | - > 62 | DOMAIN={{cookiecutter.domain_main}} 63 | TRAEFIK_TAG={{cookiecutter.traefik_constraint_tag}} 64 | STACK_NAME={{cookiecutter.docker_swarm_stack_name_main}} 65 | TAG=prod 66 | sh ./scripts/deploy.sh 67 | environment: 68 | name: production 69 | url: https://{{cookiecutter.domain_main}} 70 | only: 71 | - production 72 | tags: 73 | - swarm 74 | - prod 75 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | mypy = "*" 8 | black = "*" 9 | jupyter = "*" 10 | isort = "*" 11 | autoflake = "*" 12 | flake8 = "*" 13 | pytest = "*" 14 | vulture = "*" 15 | 16 | [packages] 17 | fastapi = "*" 18 | uvicorn = "*" 19 | pyjwt = "*" 20 | python-multipart = "*" 21 | email-validator = "*" 22 | requests = "*" 23 | celery = "~=4.3" 24 | passlib = {extras = ["bcrypt"],version = "*"} 25 | tenacity = "*" 26 | pydantic = "*" 27 | couchbase = "*" 28 | emails = "*" 29 | raven = "*" 30 | jinja2 = "*" 31 | 32 | [requires] 33 | python_version = "3.6" 34 | 35 | [pipenv] 36 | allow_prereleases = true 37 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/backend/app/app/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/backend/app/app/api/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/api.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from app.api.api_v1.endpoints import items, login, roles, users, utils 4 | 5 | api_router = APIRouter() 6 | api_router.include_router(login.router, tags=["login"]) 7 | api_router.include_router(roles.router, prefix="/roles", tags=["roles"]) 8 | api_router.include_router(users.router, prefix="/users", tags=["users"]) 9 | api_router.include_router(utils.router, prefix="/utils", tags=["utils"]) 10 | api_router.include_router(items.router, prefix="/items", tags=["items"]) 11 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/items.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from fastapi import APIRouter, Depends, HTTPException 4 | 5 | from app import crud 6 | from app.api.utils.security import get_current_active_user 7 | from app.db.database import get_default_bucket 8 | from app.models.item import Item, ItemCreate, ItemUpdate 9 | from app.models.user import UserInDB 10 | 11 | router = APIRouter() 12 | 13 | 14 | @router.get("/", response_model=List[Item]) 15 | def read_items( 16 | skip: int = 0, 17 | limit: int = 100, 18 | current_user: UserInDB = Depends(get_current_active_user), 19 | ): 20 | """ 21 | Retrieve items. 22 | 23 | If superuser, all the items. 24 | 25 | If normal user, the items owned by this user. 26 | """ 27 | bucket = get_default_bucket() 28 | if crud.user.is_superuser(current_user): 29 | docs = crud.item.get_multi(bucket, skip=skip, limit=limit) 30 | else: 31 | docs = crud.item.get_multi_by_owner( 32 | bucket=bucket, owner_username=current_user.username, skip=skip, limit=limit 33 | ) 34 | return docs 35 | 36 | 37 | @router.get("/search/", response_model=List[Item]) 38 | def search_items( 39 | q: str, 40 | skip: int = 0, 41 | limit: int = 100, 42 | current_user: UserInDB = Depends(get_current_active_user), 43 | ): 44 | """ 45 | Search items, use Bleve Query String syntax: 46 | http://blevesearch.com/docs/Query-String-Query/ 47 | 48 | For typeahead suffix with `*`. For example, a query with: `title:foo*` will match 49 | items containing `football`, `fool proof`, etc. 50 | """ 51 | bucket = get_default_bucket() 52 | if crud.user.is_superuser(current_user): 53 | docs = crud.item.search(bucket=bucket, query_string=q, skip=skip, limit=limit) 54 | else: 55 | docs = crud.item.search_with_owner( 56 | bucket=bucket, 57 | query_string=q, 58 | username=current_user.username, 59 | skip=skip, 60 | limit=limit, 61 | ) 62 | return docs 63 | 64 | 65 | @router.post("/", response_model=Item) 66 | def create_item( 67 | *, item_in: ItemCreate, current_user: UserInDB = Depends(get_current_active_user) 68 | ): 69 | """ 70 | Create new item. 71 | """ 72 | bucket = get_default_bucket() 73 | id = crud.utils.generate_new_id() 74 | doc = crud.item.upsert( 75 | bucket=bucket, id=id, doc_in=item_in, owner_username=current_user.username 76 | ) 77 | return doc 78 | 79 | 80 | @router.put("/{id}", response_model=Item) 81 | def update_item( 82 | *, 83 | id: str, 84 | item_in: ItemUpdate, 85 | current_user: UserInDB = Depends(get_current_active_user), 86 | ): 87 | """ 88 | Update an item. 89 | """ 90 | bucket = get_default_bucket() 91 | doc = crud.item.get(bucket=bucket, id=id) 92 | if not doc: 93 | raise HTTPException(status_code=404, detail="Item not found") 94 | if not crud.user.is_superuser(current_user) and ( 95 | doc.owner_username != current_user.username 96 | ): 97 | raise HTTPException(status_code=400, detail="Not enough permissions") 98 | doc = crud.item.update( 99 | bucket=bucket, id=id, doc_in=item_in, owner_username=doc.owner_username 100 | ) 101 | return doc 102 | 103 | 104 | @router.get("/{id}", response_model=Item) 105 | def read_item(id: str, current_user: UserInDB = Depends(get_current_active_user)): 106 | """ 107 | Get item by ID. 108 | """ 109 | bucket = get_default_bucket() 110 | doc = crud.item.get(bucket=bucket, id=id) 111 | if not doc: 112 | raise HTTPException(status_code=404, detail="Item not found") 113 | if not crud.user.is_superuser(current_user) and ( 114 | doc.owner_username != current_user.username 115 | ): 116 | raise HTTPException(status_code=400, detail="Not enough permissions") 117 | return doc 118 | 119 | 120 | @router.delete("/{id}", response_model=Item) 121 | def delete_item(id: str, current_user: UserInDB = Depends(get_current_active_user)): 122 | """ 123 | Delete an item by ID. 124 | """ 125 | bucket = get_default_bucket() 126 | doc = crud.item.get(bucket=bucket, id=id) 127 | if not doc: 128 | raise HTTPException(status_code=404, detail="Item not found") 129 | if not crud.user.is_superuser(current_user) and ( 130 | doc.owner_username != current_user.username 131 | ): 132 | raise HTTPException(status_code=400, detail="Not enough permissions") 133 | doc = crud.item.remove(bucket=bucket, id=id) 134 | return doc 135 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/login.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from fastapi import APIRouter, Body, Depends, HTTPException 4 | from fastapi.security import OAuth2PasswordRequestForm 5 | 6 | from app import crud 7 | from app.api.utils.security import get_current_user 8 | from app.core import config 9 | from app.core.jwt import create_access_token 10 | from app.db.database import get_default_bucket 11 | from app.models.msg import Msg 12 | from app.models.token import Token 13 | from app.models.user import User, UserInDB, UserUpdate 14 | from app.utils import ( 15 | generate_password_reset_token, 16 | send_reset_password_email, 17 | verify_password_reset_token, 18 | ) 19 | 20 | router = APIRouter() 21 | 22 | 23 | @router.post("/login/access-token", response_model=Token) 24 | def login(form_data: OAuth2PasswordRequestForm = Depends()): 25 | """ 26 | OAuth2 compatible token login, get an access token for future requests. 27 | """ 28 | bucket = get_default_bucket() 29 | user = crud.user.authenticate( 30 | bucket, username=form_data.username, password=form_data.password 31 | ) 32 | if not user: 33 | raise HTTPException(status_code=400, detail="Incorrect email or password") 34 | elif not crud.user.is_active(user): 35 | raise HTTPException(status_code=400, detail="Inactive user") 36 | access_token_expires = timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES) 37 | return { 38 | "access_token": create_access_token( 39 | data={"username": user.username}, expires_delta=access_token_expires 40 | ), 41 | "token_type": "bearer", 42 | } 43 | 44 | 45 | @router.post("/login/test-token", response_model=User) 46 | def test_token(current_user: UserInDB = Depends(get_current_user)): 47 | """ 48 | Test access token. 49 | """ 50 | return current_user 51 | 52 | 53 | @router.post("/password-recovery/{username}", response_model=Msg) 54 | def recover_password(username: str): 55 | """ 56 | Password Recovery. 57 | """ 58 | bucket = get_default_bucket() 59 | user = crud.user.get(bucket, username=username) 60 | 61 | if not user: 62 | raise HTTPException( 63 | status_code=404, 64 | detail="The user with this username does not exist in the system.", 65 | ) 66 | password_reset_token = generate_password_reset_token(username=username) 67 | send_reset_password_email( 68 | email_to=user.email, username=username, token=password_reset_token 69 | ) 70 | return {"msg": "Password recovery email sent"} 71 | 72 | 73 | @router.post("/reset-password/", response_model=Msg) 74 | def reset_password(token: str = Body(...), new_password: str = Body(...)): 75 | """ 76 | Reset password. 77 | """ 78 | username = verify_password_reset_token(token) 79 | if not username: 80 | raise HTTPException(status_code=400, detail="Invalid token") 81 | bucket = get_default_bucket() 82 | user = crud.user.get(bucket, username=username) 83 | if not user: 84 | raise HTTPException( 85 | status_code=404, 86 | detail="The user with this username does not exist in the system.", 87 | ) 88 | elif not crud.user.is_active(user): 89 | raise HTTPException(status_code=400, detail="Inactive user") 90 | user_in = UserUpdate(name=username, password=new_password) 91 | user = crud.user.update(bucket, username=username, user_in=user_in) 92 | return {"msg": "Password updated successfully"} 93 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/roles.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | 3 | from app import crud 4 | from app.api.utils.security import get_current_active_superuser 5 | from app.models.role import RoleEnum, Roles 6 | from app.models.user import UserInDB 7 | 8 | router = APIRouter() 9 | 10 | 11 | @router.get("/", response_model=Roles) 12 | def read_roles(current_user: UserInDB = Depends(get_current_active_superuser)): 13 | """ 14 | Retrieve roles. 15 | """ 16 | roles = crud.utils.ensure_enums_to_strs(RoleEnum) 17 | return {"roles": roles} 18 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/users.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from fastapi import APIRouter, Body, Depends, HTTPException 4 | from pydantic.networks import EmailStr 5 | 6 | from app import crud 7 | from app.api.utils.security import get_current_active_superuser, get_current_active_user 8 | from app.core import config 9 | from app.db.database import get_default_bucket 10 | from app.models.user import User, UserCreate, UserInDB, UserUpdate 11 | from app.utils import send_new_account_email 12 | 13 | router = APIRouter() 14 | 15 | 16 | @router.get("/", response_model=List[User]) 17 | def read_users( 18 | skip: int = 0, 19 | limit: int = 100, 20 | current_user: UserInDB = Depends(get_current_active_superuser), 21 | ): 22 | """ 23 | Retrieve users. 24 | """ 25 | bucket = get_default_bucket() 26 | users = crud.user.get_multi(bucket, skip=skip, limit=limit) 27 | return users 28 | 29 | 30 | @router.get("/search/", response_model=List[User]) 31 | def search_users( 32 | q: str, 33 | skip: int = 0, 34 | limit: int = 100, 35 | current_user: UserInDB = Depends(get_current_active_superuser), 36 | ): 37 | """ 38 | Search users, use Bleve Query String syntax: 39 | http://blevesearch.com/docs/Query-String-Query/ 40 | 41 | For typeahead suffix with `*`. For example, a query with: `email:johnd*` will match 42 | users with email `johndoe@example.com`, `johndid@example.net`, etc. 43 | """ 44 | bucket = get_default_bucket() 45 | users = crud.user.search(bucket=bucket, query_string=q, skip=skip, limit=limit) 46 | return users 47 | 48 | 49 | @router.post("/", response_model=User) 50 | def create_user( 51 | *, 52 | user_in: UserCreate, 53 | current_user: UserInDB = Depends(get_current_active_superuser), 54 | ): 55 | """ 56 | Create new user. 57 | """ 58 | bucket = get_default_bucket() 59 | user = crud.user.get(bucket, username=user_in.username) 60 | if user: 61 | raise HTTPException( 62 | status_code=400, 63 | detail="The user with this username already exists in the system.", 64 | ) 65 | user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) 66 | if config.EMAILS_ENABLED and user_in.email: 67 | send_new_account_email( 68 | email_to=user_in.email, username=user_in.username, password=user_in.password 69 | ) 70 | return user 71 | 72 | 73 | @router.put("/me", response_model=User) 74 | def update_user_me( 75 | *, 76 | password: str = Body(None), 77 | full_name: str = Body(None), 78 | email: EmailStr = Body(None), 79 | current_user: UserInDB = Depends(get_current_active_user), 80 | ): 81 | """ 82 | Update own user. 83 | """ 84 | user_in = UserUpdate(**current_user.dict()) 85 | if password is not None: 86 | user_in.password = password 87 | if full_name is not None: 88 | user_in.full_name = full_name 89 | if email is not None: 90 | user_in.email = email 91 | bucket = get_default_bucket() 92 | user = crud.user.update(bucket, username=current_user.username, user_in=user_in) 93 | return user 94 | 95 | 96 | @router.get("/me", response_model=User) 97 | def read_user_me(current_user: UserInDB = Depends(get_current_active_user)): 98 | """ 99 | Get current user. 100 | """ 101 | return current_user 102 | 103 | 104 | @router.post("/open", response_model=User) 105 | def create_user_open( 106 | *, 107 | username: str = Body(...), 108 | password: str = Body(...), 109 | email: EmailStr = Body(None), 110 | full_name: str = Body(None), 111 | ): 112 | """ 113 | Create new user without the need to be logged in. 114 | """ 115 | if not config.USERS_OPEN_REGISTRATION: 116 | raise HTTPException( 117 | status_code=403, 118 | detail="Open user resistration is forbidden on this server", 119 | ) 120 | bucket = get_default_bucket() 121 | user = crud.user.get(bucket, username=username) 122 | if user: 123 | raise HTTPException( 124 | status_code=400, 125 | detail="The user with this username already exists in the system", 126 | ) 127 | user_in = UserCreate( 128 | username=username, password=password, email=email, full_name=full_name 129 | ) 130 | user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) 131 | if config.EMAILS_ENABLED and user_in.email: 132 | send_new_account_email( 133 | email_to=user_in.email, username=user_in.username, password=user_in.password 134 | ) 135 | return user 136 | 137 | 138 | @router.get("/{username}", response_model=User) 139 | def read_user(username: str, current_user: UserInDB = Depends(get_current_active_user)): 140 | """ 141 | Get a specific user by username (email). 142 | """ 143 | bucket = get_default_bucket() 144 | user = crud.user.get(bucket, username=username) 145 | if user == current_user: 146 | return user 147 | if not crud.user.is_superuser(current_user): 148 | raise HTTPException( 149 | status_code=400, detail="The user doesn't have enough privileges" 150 | ) 151 | return user 152 | 153 | 154 | @router.put("/{username}", response_model=User) 155 | def update_user( 156 | *, 157 | username: str, 158 | user_in: UserUpdate, 159 | current_user: UserInDB = Depends(get_current_active_superuser), 160 | ): 161 | """ 162 | Update a user. 163 | """ 164 | bucket = get_default_bucket() 165 | user = crud.user.get(bucket, username=username) 166 | 167 | if not user: 168 | raise HTTPException( 169 | status_code=404, 170 | detail="The user with this username does not exist in the system", 171 | ) 172 | user = crud.user.update(bucket, username=username, user_in=user_in) 173 | return user 174 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/utils.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | from pydantic.networks import EmailStr 3 | 4 | from app.api.utils.security import get_current_active_superuser 5 | from app.core.celery_app import celery_app 6 | from app.models.msg import Msg 7 | from app.models.user import UserInDB 8 | from app.utils import send_test_email 9 | 10 | router = APIRouter() 11 | 12 | 13 | @router.post("/test-celery/", response_model=Msg, status_code=201) 14 | def test_celery( 15 | msg: Msg, current_user: UserInDB = Depends(get_current_active_superuser) 16 | ): 17 | """ 18 | Test Celery worker. 19 | """ 20 | celery_app.send_task("app.worker.test_celery", args=[msg.msg]) 21 | return {"msg": "Word received"} 22 | 23 | 24 | @router.post("/test-email/", response_model=Msg, status_code=201) 25 | def test_email( 26 | email_to: EmailStr, current_user: UserInDB = Depends(get_current_active_superuser) 27 | ): 28 | """ 29 | Test emails. 30 | """ 31 | send_test_email(email_to=email_to) 32 | return {"msg": "Test email sent"} 33 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/backend/app/app/api/utils/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/api/utils/security.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | from fastapi import HTTPException, Security 3 | from fastapi.security import OAuth2PasswordBearer 4 | from jwt import PyJWTError 5 | from starlette.status import HTTP_403_FORBIDDEN 6 | 7 | from app import crud 8 | from app.core import config 9 | from app.core.jwt import ALGORITHM 10 | from app.db.database import get_default_bucket 11 | from app.models.token import TokenPayload 12 | from app.models.user import UserInDB 13 | 14 | reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="/api/v1/login/access-token") 15 | 16 | 17 | def get_current_user(token: str = Security(reusable_oauth2)): 18 | try: 19 | payload = jwt.decode(token, config.SECRET_KEY, algorithms=[ALGORITHM]) 20 | token_data = TokenPayload(**payload) 21 | except PyJWTError: 22 | raise HTTPException( 23 | status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials" 24 | ) 25 | bucket = get_default_bucket() 26 | user = crud.user.get(bucket, username=token_data.username) 27 | if not user: 28 | raise HTTPException(status_code=404, detail="User not found") 29 | return user 30 | 31 | 32 | def get_current_active_user(current_user: UserInDB = Security(get_current_user)): 33 | if not crud.user.is_active(current_user): 34 | raise HTTPException(status_code=400, detail="Inactive user") 35 | return current_user 36 | 37 | 38 | def get_current_active_superuser(current_user: UserInDB = Security(get_current_user)): 39 | if not crud.user.is_superuser(current_user): 40 | raise HTTPException( 41 | status_code=400, detail="The user doesn't have enough privileges" 42 | ) 43 | return current_user 44 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/backend_pre_start.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed 4 | 5 | from app.db.init_db import init_db 6 | 7 | logging.basicConfig(level=logging.INFO) 8 | logger = logging.getLogger(__name__) 9 | 10 | max_tries = 60 * 5 # 5 minutes 11 | wait_seconds = 1 12 | 13 | 14 | @retry( 15 | stop=stop_after_attempt(max_tries), 16 | wait=wait_fixed(wait_seconds), 17 | before=before_log(logger, logging.INFO), 18 | after=after_log(logger, logging.WARN), 19 | ) 20 | def init(): 21 | try: 22 | init_db() 23 | except Exception as e: 24 | logger.error(e) 25 | raise e 26 | 27 | 28 | def main(): 29 | logger.info("Initializing service") 30 | init() 31 | logger.info("Service finished initializing") 32 | 33 | 34 | if __name__ == "__main__": 35 | main() 36 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/celeryworker_pre_start.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed 4 | 5 | from app.db.database import get_default_bucket 6 | 7 | logging.basicConfig(level=logging.INFO) 8 | logger = logging.getLogger(__name__) 9 | 10 | max_tries = 60 * 5 # 5 minutes 11 | wait_seconds = 1 12 | 13 | 14 | @retry( 15 | stop=stop_after_attempt(max_tries), 16 | wait=wait_fixed(wait_seconds), 17 | before=before_log(logger, logging.INFO), 18 | after=after_log(logger, logging.WARN), 19 | ) 20 | def init(): 21 | try: 22 | # Check Couchbase is awake 23 | bucket = get_default_bucket() 24 | logger.info( 25 | f"Database bucket connection established with bucket object: {bucket}" 26 | ) 27 | except Exception as e: 28 | logger.error(e) 29 | raise e 30 | 31 | 32 | def main(): 33 | logger.info("Initializing service") 34 | init() 35 | logger.info("Service finished initializing") 36 | 37 | 38 | if __name__ == "__main__": 39 | main() 40 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/backend/app/app/core/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/core/celery_app.py: -------------------------------------------------------------------------------- 1 | from celery import Celery 2 | 3 | celery_app = Celery("worker", broker="amqp://guest@queue//") 4 | 5 | celery_app.conf.task_routes = {"app.worker.test_celery": "main-queue"} 6 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/core/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def getenv_boolean(var_name, default_value=False): 5 | result = default_value 6 | env_value = os.getenv(var_name) 7 | if env_value is not None: 8 | result = env_value.upper() in ("TRUE", "1") 9 | return result 10 | 11 | 12 | API_V1_STR = "/api/v1" 13 | 14 | SECRET_KEY = os.getenvb(b"SECRET_KEY") 15 | if not SECRET_KEY: 16 | SECRET_KEY = os.urandom(32) 17 | 18 | ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 8 # 60 minutes * 24 hours * 8 days = 8 days 19 | 20 | SERVER_NAME = os.getenv("SERVER_NAME") 21 | SERVER_HOST = os.getenv("SERVER_HOST") 22 | BACKEND_CORS_ORIGINS = os.getenv( 23 | "BACKEND_CORS_ORIGINS" 24 | ) # a string of origins separated by commas, e.g: "http://localhost, http://localhost:4200, http://localhost:3000, http://localhost:8080, http://dev.couchbase-project.com, https://stag.couchbase-project.com, https://couchbase-project.com, http://local.dockertoolbox.tiangolo.com" 25 | PROJECT_NAME = os.getenv("PROJECT_NAME") 26 | SENTRY_DSN = os.getenv("SENTRY_DSN") 27 | 28 | # Couchbase server settings 29 | COUCHBASE_MEMORY_QUOTA_MB = os.getenv("COUCHBASE_MEMORY_QUOTA_MB", "256") 30 | COUCHBASE_INDEX_MEMORY_QUOTA_MB = os.getenv("COUCHBASE_INDEX_MEMORY_QUOTA_MB" "256") 31 | COUCHBASE_FTS_MEMORY_QUOTA_MB = os.getenv("COUCHBASE_FTS_MEMORY_QUOTA_MB", "256") 32 | COUCHBASE_HOST = os.getenv("COUCHBASE_HOST", "couchbase") 33 | COUCHBASE_PORT = os.getenv("COUCHBASE_PORT", "8091") 34 | COUCHBASE_FULL_TEXT_PORT = os.getenv("COUCHBASE_FULL_TEXT_PORT", "8094") 35 | COUCHBASE_ENTERPRISE = getenv_boolean("COUCHBASE_ENTERPRISE") 36 | COUCHBASE_USER = os.getenv("COUCHBASE_USER", "Administrator") 37 | COUCHBASE_PASSWORD = os.getenv("COUCHBASE_PASSWORD", "password") 38 | COUCHBASE_BUCKET_NAME = os.getenv("COUCHBASE_BUCKET_NAME", "app") 39 | 40 | COUCHBASE_SYNC_GATEWAY_HOST = os.getenv("COUCHBASE_SYNC_GATEWAY_HOST", "sync-gateway") 41 | COUCHBASE_SYNC_GATEWAY_PORT = os.getenv("COUCHBASE_SYNC_GATEWAY_PORT", "4985") 42 | COUCHBASE_SYNC_GATEWAY_USER = os.getenv("COUCHBASE_SYNC_GATEWAY_USER") 43 | COUCHBASE_SYNC_GATEWAY_PASSWORD = os.getenv("COUCHBASE_SYNC_GATEWAY_PASSWORD") 44 | COUCHBASE_SYNC_GATEWAY_DATABASE = os.getenv("COUCHBASE_SYNC_GATEWAY_DATABASE") 45 | 46 | # Couchbase query timeouts 47 | COUCHBASE_DURABILITY_TIMEOUT_SECS = 60.0 48 | COUCHBASE_OPERATION_TIMEOUT_SECS = 30.0 49 | COUCHBASE_N1QL_TIMEOUT_SECS = 300.0 50 | 51 | 52 | # Couchbase Sync Gateway settings 53 | COUCHBASE_CORS_ORIGINS = os.getenv("COUCHBASE_CORS_ORIGINS") 54 | # a string of origins separated by commas, e.g: "http://localhost:5984, http://localhost, http://localhost:4200, http://localhost:3000, http://localhost:8080, http://dev.couchbase-project.com, https://stag.couchbase-project.com, https://db.stag.couchbase-project.com, https://couchbase-project.com, https://db.couchbase-project.com, http://local.dockertoolbox.tiangolo.com, http://local.dockertoolbox.tiangolo.com:5984" 55 | COUCHBASE_AUTH_TIMEOUT = ACCESS_TOKEN_EXPIRE_MINUTES * 60 56 | 57 | COUCHBASE_FULL_TEXT_INDEX_DEFINITIONS_DIR = "/app/app/search_index_definitions/" 58 | 59 | SMTP_TLS = getenv_boolean("SMTP_TLS", True) 60 | SMTP_PORT = None 61 | _SMTP_PORT = os.getenv("SMTP_PORT") 62 | if _SMTP_PORT is not None: 63 | SMTP_PORT = int(_SMTP_PORT) 64 | SMTP_HOST = os.getenv("SMTP_HOST") 65 | SMTP_USER = os.getenv("SMTP_USER") 66 | SMTP_PASSWORD = os.getenv("SMTP_PASSWORD") 67 | EMAILS_FROM_EMAIL = os.getenv("EMAILS_FROM_EMAIL") 68 | EMAILS_FROM_NAME = PROJECT_NAME 69 | EMAIL_RESET_TOKEN_EXPIRE_HOURS = 48 70 | EMAIL_TEMPLATES_DIR = "/app/app/email-templates/build" 71 | EMAILS_ENABLED = SMTP_HOST and SMTP_PORT and EMAILS_FROM_EMAIL 72 | 73 | ROLE_SUPERUSER = "superuser" 74 | 75 | FIRST_SUPERUSER = os.getenv("FIRST_SUPERUSER") 76 | FIRST_SUPERUSER_PASSWORD = os.getenv("FIRST_SUPERUSER_PASSWORD") 77 | 78 | USERS_OPEN_REGISTRATION = getenv_boolean("USERS_OPEN_REGISTRATION") 79 | 80 | EMAIL_TEST_USER = "test@example.com" 81 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/core/jwt.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import jwt 4 | 5 | from app.core import config 6 | 7 | ALGORITHM = "HS256" 8 | access_token_jwt_subject = "access" 9 | 10 | 11 | def create_access_token(*, data: dict, expires_delta: timedelta = None): 12 | to_encode = data.copy() 13 | if expires_delta: 14 | expire = datetime.utcnow() + expires_delta 15 | else: 16 | expire = datetime.utcnow() + timedelta(minutes=15) 17 | to_encode.update({"exp": expire, "sub": access_token_jwt_subject}) 18 | encoded_jwt = jwt.encode(to_encode, config.SECRET_KEY, algorithm=ALGORITHM) 19 | return encoded_jwt 20 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/core/security.py: -------------------------------------------------------------------------------- 1 | from passlib.context import CryptContext 2 | 3 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 4 | 5 | 6 | def verify_password(plain_password: str, hashed_password: str): 7 | return pwd_context.verify(plain_password, hashed_password) 8 | 9 | 10 | def get_password_hash(password: str): 11 | return pwd_context.hash(password) 12 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py: -------------------------------------------------------------------------------- 1 | from . import item, user, utils 2 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/crud/item.py: -------------------------------------------------------------------------------- 1 | from couchbase.bucket import Bucket 2 | from couchbase.n1ql import CONSISTENCY_REQUEST, N1QLQuery 3 | 4 | from app.core import config 5 | from app.models.config import ITEM_DOC_TYPE 6 | from app.models.item import ItemCreate, ItemInDB, ItemUpdate 7 | 8 | from . import utils 9 | 10 | # Same as file name /app/app/search_index_definitions/items.json 11 | full_text_index_name = "items" 12 | 13 | 14 | def get_doc_id(id: str): 15 | return f"{ITEM_DOC_TYPE}::{id}" 16 | 17 | 18 | def get(bucket: Bucket, *, id: str): 19 | doc_id = get_doc_id(id) 20 | return utils.get_doc(bucket=bucket, doc_id=doc_id, doc_model=ItemInDB) 21 | 22 | 23 | def upsert( 24 | bucket: Bucket, 25 | *, 26 | id: str, 27 | doc_in: ItemCreate, 28 | owner_username: str, 29 | persist_to=0, 30 | ttl=0, 31 | ): 32 | doc_id = get_doc_id(id) 33 | doc = ItemInDB(**doc_in.dict(), id=id, owner_username=owner_username) 34 | return utils.upsert( 35 | bucket=bucket, doc_id=doc_id, doc_in=doc, persist_to=persist_to, ttl=ttl 36 | ) 37 | 38 | 39 | def update( 40 | bucket: Bucket, 41 | *, 42 | id: str, 43 | doc_in: ItemUpdate, 44 | owner_username=None, 45 | persist_to=0, 46 | ttl=0, 47 | ): 48 | doc_id = get_doc_id(id=id) 49 | doc = get(bucket, id=id) 50 | doc = doc.copy(update=doc_in.dict(skip_defaults=True)) 51 | if owner_username is not None: 52 | doc.owner_username = owner_username 53 | return utils.upsert( 54 | bucket=bucket, doc_id=doc_id, doc_in=doc, persist_to=persist_to, ttl=ttl 55 | ) 56 | 57 | 58 | def remove(bucket: Bucket, *, id: str, persist_to=0): 59 | doc_id = get_doc_id(id) 60 | return utils.remove( 61 | bucket=bucket, doc_id=doc_id, doc_model=ItemInDB, persist_to=persist_to 62 | ) 63 | 64 | 65 | def get_multi(bucket: Bucket, *, skip=0, limit=100): 66 | return utils.get_docs( 67 | bucket=bucket, 68 | doc_type=ITEM_DOC_TYPE, 69 | doc_model=ItemInDB, 70 | skip=skip, 71 | limit=limit, 72 | ) 73 | 74 | 75 | def get_multi_by_owner(bucket: Bucket, *, owner_username: str, skip=0, limit=100): 76 | query_str = f"SELECT *, META().id as doc_id FROM {config.COUCHBASE_BUCKET_NAME} WHERE type = $type AND owner_username = $owner_username LIMIT $limit OFFSET $skip;" 77 | q = N1QLQuery( 78 | query_str, 79 | bucket=config.COUCHBASE_BUCKET_NAME, 80 | type=ITEM_DOC_TYPE, 81 | owner_username=owner_username, 82 | limit=limit, 83 | skip=skip, 84 | ) 85 | q.consistency = CONSISTENCY_REQUEST 86 | doc_results = bucket.n1ql_query(q) 87 | return utils.doc_results_to_model(doc_results, doc_model=ItemInDB) 88 | 89 | 90 | def search(bucket: Bucket, *, query_string: str, skip=0, limit=100): 91 | docs = utils.search_get_docs( 92 | bucket=bucket, 93 | query_string=query_string, 94 | index_name=full_text_index_name, 95 | doc_model=ItemInDB, 96 | skip=skip, 97 | limit=limit, 98 | ) 99 | return docs 100 | 101 | 102 | def search_with_owner( 103 | bucket: Bucket, *query_string: str, username: str, skip=0, limit=100 104 | ): 105 | username_filter = f"owner_username:{username}" 106 | if username_filter not in query_string: 107 | query_string = f"{query_string} {username_filter}" 108 | docs = utils.search_get_docs( 109 | bucket=bucket, 110 | query_string=query_string, 111 | index_name=full_text_index_name, 112 | doc_model=ItemInDB, 113 | skip=skip, 114 | limit=limit, 115 | ) 116 | return docs 117 | 118 | 119 | def search_get_search_results_to_docs( 120 | bucket: Bucket, *, query_string: str, skip=0, limit=100 121 | ): 122 | docs = utils.search_by_type_get_results_to_docs( 123 | bucket=bucket, 124 | query_string=query_string, 125 | index_name=full_text_index_name, 126 | doc_type=ITEM_DOC_TYPE, 127 | doc_model=ItemInDB, 128 | skip=skip, 129 | limit=limit, 130 | ) 131 | return docs 132 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/crud/user.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from couchbase.bucket import Bucket 3 | from couchbase.n1ql import CONSISTENCY_REQUEST, N1QLQuery 4 | from fastapi.encoders import jsonable_encoder 5 | 6 | from app.core import config 7 | from app.core.security import get_password_hash, verify_password 8 | from app.models.config import USERPROFILE_DOC_TYPE 9 | from app.models.role import RoleEnum 10 | from app.models.user import UserCreate, UserInDB, UserSyncIn, UserUpdate 11 | 12 | from . import utils 13 | 14 | # Same as file name /app/app/search_index_definitions/users.json 15 | full_text_index_name = "users" 16 | 17 | 18 | def get_doc_id(username: str): 19 | return f"{USERPROFILE_DOC_TYPE}::{username}" 20 | 21 | 22 | def get(bucket: Bucket, *, username: str): 23 | doc_id = get_doc_id(username) 24 | return utils.get_doc(bucket=bucket, doc_id=doc_id, doc_model=UserInDB) 25 | 26 | 27 | def get_by_email(bucket: Bucket, *, email: str): 28 | query_str = f"SELECT *, META().id as doc_id FROM {config.COUCHBASE_BUCKET_NAME} WHERE type = $type AND email = $email;" 29 | q = N1QLQuery( 30 | query_str, 31 | bucket=config.COUCHBASE_BUCKET_NAME, 32 | type=USERPROFILE_DOC_TYPE, 33 | email=email, 34 | ) 35 | q.consistency = CONSISTENCY_REQUEST 36 | doc_results = bucket.n1ql_query(q) 37 | users = utils.doc_results_to_model(doc_results, doc_model=UserInDB) 38 | if not users: 39 | return None 40 | return users[0] 41 | 42 | 43 | def insert_sync_gateway(user: UserSyncIn): 44 | name = user.name 45 | url = f"http://{config.COUCHBASE_SYNC_GATEWAY_HOST}:{config.COUCHBASE_SYNC_GATEWAY_PORT}/{config.COUCHBASE_SYNC_GATEWAY_DATABASE}/_user/{name}" 46 | data = jsonable_encoder(user) 47 | response = requests.put(url, json=data) 48 | return response.status_code == 200 or response.status_code == 201 49 | 50 | 51 | def update_sync_gateway(user: UserSyncIn): 52 | name = user.name 53 | url = f"http://{config.COUCHBASE_SYNC_GATEWAY_HOST}:{config.COUCHBASE_SYNC_GATEWAY_PORT}/{config.COUCHBASE_SYNC_GATEWAY_DATABASE}/_user/{name}" 54 | if user.password: 55 | data = jsonable_encoder(user) 56 | else: 57 | data = jsonable_encoder(user, exclude={"password"}) 58 | response = requests.put(url, json=data) 59 | return response.status_code == 200 or response.status_code == 201 60 | 61 | 62 | def upsert_in_db(bucket: Bucket, *, user_in: UserCreate, persist_to=0): 63 | user_doc_id = get_doc_id(user_in.username) 64 | passwordhash = get_password_hash(user_in.password) 65 | user = UserInDB(**user_in.dict(), hashed_password=passwordhash) 66 | doc_data = jsonable_encoder(user) 67 | with bucket.durability( 68 | persist_to=persist_to, timeout=config.COUCHBASE_DURABILITY_TIMEOUT_SECS 69 | ): 70 | bucket.upsert(user_doc_id, doc_data) 71 | return user 72 | 73 | 74 | def update_in_db(bucket: Bucket, *, username: str, user_in: UserUpdate, persist_to=0): 75 | user_doc_id = get_doc_id(username) 76 | stored_user = get(bucket, username=username) 77 | stored_user = stored_user.copy(update=user_in.dict(skip_defaults=True)) 78 | if user_in.password: 79 | passwordhash = get_password_hash(user_in.password) 80 | stored_user.hashed_password = passwordhash 81 | data = jsonable_encoder(stored_user) 82 | with bucket.durability( 83 | persist_to=persist_to, timeout=config.COUCHBASE_DURABILITY_TIMEOUT_SECS 84 | ): 85 | bucket.upsert(user_doc_id, data) 86 | return stored_user 87 | 88 | 89 | def upsert(bucket: Bucket, *, user_in: UserCreate, persist_to=0): 90 | user = upsert_in_db(bucket, user_in=user_in, persist_to=persist_to) 91 | user_in_sync = UserSyncIn(**user_in.dict(), name=user_in.username) 92 | assert insert_sync_gateway(user_in_sync) 93 | return user 94 | 95 | 96 | def update(bucket: Bucket, *, username: str, user_in: UserUpdate, persist_to=0): 97 | user = update_in_db( 98 | bucket, username=username, user_in=user_in, persist_to=persist_to 99 | ) 100 | user_in_sync_data = user.dict() 101 | user_in_sync_data.update({"name": user.username}) 102 | if user_in.password: 103 | user_in_sync_data.update({"password": user_in.password}) 104 | user_in_sync = UserSyncIn(**user_in_sync_data) 105 | assert update_sync_gateway(user_in_sync) 106 | return user 107 | 108 | 109 | def authenticate(bucket: Bucket, *, username: str, password: str): 110 | user = get(bucket, username=username) 111 | if not user: 112 | return None 113 | if not verify_password(password, user.hashed_password): 114 | return None 115 | return user 116 | 117 | 118 | def is_active(user: UserInDB): 119 | return not user.disabled 120 | 121 | 122 | def is_superuser(user: UserInDB): 123 | return RoleEnum.superuser.value in utils.ensure_enums_to_strs( 124 | user.admin_roles or [] 125 | ) 126 | 127 | 128 | def get_multi(bucket: Bucket, *, skip=0, limit=100): 129 | users = utils.get_docs( 130 | bucket=bucket, 131 | doc_type=USERPROFILE_DOC_TYPE, 132 | doc_model=UserInDB, 133 | skip=skip, 134 | limit=limit, 135 | ) 136 | return users 137 | 138 | 139 | def search(bucket: Bucket, *, query_string: str, skip=0, limit=100): 140 | users = utils.search_get_docs( 141 | bucket=bucket, 142 | query_string=query_string, 143 | index_name=full_text_index_name, 144 | doc_model=UserInDB, 145 | doc_type=USERPROFILE_DOC_TYPE, 146 | skip=skip, 147 | limit=limit, 148 | ) 149 | return users 150 | 151 | 152 | def search_get_search_results_to_docs( 153 | bucket: Bucket, *, query_string: str, skip=0, limit=100 154 | ): 155 | users = utils.search_by_type_get_results_to_docs( 156 | bucket=bucket, 157 | query_string=query_string, 158 | index_name=full_text_index_name, 159 | doc_type=USERPROFILE_DOC_TYPE, 160 | doc_model=UserInDB, 161 | skip=skip, 162 | limit=limit, 163 | ) 164 | return users 165 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/backend/app/app/db/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/db/database.py: -------------------------------------------------------------------------------- 1 | from couchbase import LOCKMODE_WAIT 2 | from couchbase.bucket import Bucket 3 | from couchbase.cluster import Cluster, PasswordAuthenticator 4 | 5 | from app.core.config import ( 6 | COUCHBASE_BUCKET_NAME, 7 | COUCHBASE_HOST, 8 | COUCHBASE_N1QL_TIMEOUT_SECS, 9 | COUCHBASE_OPERATION_TIMEOUT_SECS, 10 | COUCHBASE_PASSWORD, 11 | COUCHBASE_PORT, 12 | COUCHBASE_USER, 13 | ) 14 | from app.db.couchbase_utils import get_cluster_couchbase_url 15 | 16 | 17 | def get_default_bucket(): 18 | return get_bucket( 19 | COUCHBASE_USER, 20 | COUCHBASE_PASSWORD, 21 | COUCHBASE_BUCKET_NAME, 22 | host=COUCHBASE_HOST, 23 | port=COUCHBASE_PORT, 24 | ) 25 | 26 | 27 | def get_cluster(username: str, password: str, host="couchbase", port="8091"): 28 | # cluster_url="couchbase://couchbase" 29 | # username = "Administrator" 30 | # password = "password" 31 | cluster_url = get_cluster_couchbase_url(host=host, port=port) 32 | cluster = Cluster(cluster_url) 33 | authenticator = PasswordAuthenticator(username, password) 34 | cluster.authenticate(authenticator) 35 | return cluster 36 | 37 | 38 | def get_bucket( 39 | username: str, 40 | password: str, 41 | bucket_name: str, 42 | host="couchbase", 43 | port="8091", 44 | timeout: float = COUCHBASE_OPERATION_TIMEOUT_SECS, 45 | n1ql_timeout: float = COUCHBASE_N1QL_TIMEOUT_SECS, 46 | ): 47 | cluster = get_cluster(username, password, host=host, port=port) 48 | bucket: Bucket = cluster.open_bucket(bucket_name, lockmode=LOCKMODE_WAIT) 49 | bucket.timeout = timeout 50 | bucket.n1ql_timeout = n1ql_timeout 51 | return bucket 52 | 53 | 54 | def ensure_create_primary_index(bucket: Bucket): 55 | manager = bucket.bucket_manager() 56 | return manager.n1ql_index_create_primary(ignore_exists=True) 57 | 58 | 59 | def ensure_create_type_index(bucket: Bucket): 60 | manager = bucket.bucket_manager() 61 | return manager.n1ql_index_create("idx_type", ignore_exists=True, fields=["type"]) 62 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/db/full_text_search_utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path, PurePath 3 | from typing import Any, Dict 4 | 5 | import requests 6 | from requests.auth import HTTPBasicAuth 7 | 8 | from app.core.config import ( 9 | COUCHBASE_FULL_TEXT_INDEX_DEFINITIONS_DIR, 10 | COUCHBASE_PASSWORD, 11 | COUCHBASE_USER, 12 | ) 13 | 14 | 15 | def get_index( 16 | index_name: str, 17 | *, 18 | username: str = COUCHBASE_USER, 19 | password: str = COUCHBASE_PASSWORD, 20 | host="couchbase", 21 | port="8094", 22 | ): 23 | full_text_url = f"http://{host}:{port}" 24 | index_url = f"{full_text_url}/api/index/{index_name}" 25 | auth = HTTPBasicAuth(username, password) 26 | response = requests.get(index_url, auth=auth) 27 | if response.status_code == 400: 28 | content = response.json() 29 | error = content.get("error") 30 | if error == "rest_auth: preparePerms, err: index not found": 31 | return None 32 | raise ValueError(error) 33 | elif response.status_code == 200: 34 | content = response.json() 35 | assert ( 36 | content.get("status") == "ok" 37 | ), "Expected a status OK communicating with Full Text Search" 38 | index_def = content.get("indexDef") 39 | return index_def 40 | raise ValueError(response.text) 41 | 42 | 43 | def create_index( 44 | index_definition: Dict[str, Any], 45 | *, 46 | reset_uuids=True, 47 | username: str = COUCHBASE_USER, 48 | password: str = COUCHBASE_PASSWORD, 49 | host="couchbase", 50 | port="8094", 51 | ): 52 | index_name = index_definition.get("name") 53 | assert index_name, "An index name is required as key in an index definition" 54 | if reset_uuids: 55 | index_definition.update({"uuid": "", "sourceUUID": ""}) 56 | full_text_url = f"http://{host}:{port}" 57 | index_url = f"{full_text_url}/api/index/{index_name}" 58 | auth = HTTPBasicAuth(username, password) 59 | response = requests.put(index_url, auth=auth, json=index_definition) 60 | content = response.json() 61 | if response.status_code == 400: 62 | error = content.get("error") 63 | if ( 64 | "cannot create index because an index with the same name already exists:" 65 | in error 66 | ): 67 | raise ValueError(error) 68 | else: 69 | raise ValueError(error) 70 | elif response.status_code == 200: 71 | assert ( 72 | content.get("status") == "ok" 73 | ), "Expected a status OK communicating with Full Text Search" 74 | return True 75 | raise ValueError(response.text) 76 | 77 | 78 | def ensure_create_full_text_indexes( 79 | index_dir=COUCHBASE_FULL_TEXT_INDEX_DEFINITIONS_DIR, 80 | username: str = COUCHBASE_USER, 81 | password: str = COUCHBASE_PASSWORD, 82 | host="couchbase", 83 | port="8094", 84 | ): 85 | file_path: PurePath 86 | for file_path in Path(index_dir).iterdir(): 87 | if file_path.name.endswith(".json"): 88 | with open(file_path) as f: 89 | index_definition = json.load(f) 90 | name = index_definition.get("name") 91 | assert name, "A full text search index definition must have a name field" 92 | current_index = get_index( 93 | index_name=name, 94 | username=username, 95 | password=password, 96 | host=host, 97 | port=port, 98 | ) 99 | if not current_index: 100 | assert create_index( 101 | index_definition=index_definition, 102 | username=username, 103 | password=password, 104 | host=host, 105 | port=port, 106 | ), "Full Text Search index could not be created" 107 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/db/init_db.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from app import crud 4 | from app.core import config 5 | from app.db.couchbase_utils import ( 6 | config_couchbase, 7 | ensure_create_bucket, 8 | ensure_create_couchbase_user, 9 | get_cluster_http_url, 10 | ) 11 | from app.db.database import ( 12 | ensure_create_primary_index, 13 | ensure_create_type_index, 14 | get_bucket, 15 | ) 16 | from app.db.full_text_search_utils import ensure_create_full_text_indexes 17 | from app.models.role import RoleEnum 18 | from app.models.user import UserCreate 19 | 20 | 21 | def init_db(): 22 | cluster_url = get_cluster_http_url( 23 | host=config.COUCHBASE_HOST, port=config.COUCHBASE_PORT 24 | ) 25 | logging.info("before config_couchbase") 26 | config_couchbase( 27 | username=config.COUCHBASE_USER, 28 | password=config.COUCHBASE_PASSWORD, 29 | host=config.COUCHBASE_HOST, 30 | port=config.COUCHBASE_PORT, 31 | ) 32 | logging.info("after config_couchbase") 33 | # COUCHBASE_USER="Administrator" 34 | # COUCHBASE_PASSWORD="password" 35 | logging.info("before ensure_create_bucket") 36 | ensure_create_bucket( 37 | cluster_url=cluster_url, 38 | username=config.COUCHBASE_USER, 39 | password=config.COUCHBASE_PASSWORD, 40 | bucket_name=config.COUCHBASE_BUCKET_NAME, 41 | ram_quota_mb=config.COUCHBASE_MEMORY_QUOTA_MB, 42 | ) 43 | logging.info("after ensure_create_bucket") 44 | logging.info("before get_bucket") 45 | bucket = get_bucket( 46 | config.COUCHBASE_USER, 47 | config.COUCHBASE_PASSWORD, 48 | config.COUCHBASE_BUCKET_NAME, 49 | host=config.COUCHBASE_HOST, 50 | port=config.COUCHBASE_PORT, 51 | ) 52 | logging.info("after get_bucket") 53 | logging.info("before ensure_create_primary_index") 54 | ensure_create_primary_index(bucket) 55 | logging.info("after ensure_create_primary_index") 56 | logging.info("before ensure_create_type_index") 57 | ensure_create_type_index(bucket) 58 | logging.info("after ensure_create_type_index") 59 | logging.info("before ensure_create_full_text_indexes") 60 | ensure_create_full_text_indexes( 61 | index_dir=config.COUCHBASE_FULL_TEXT_INDEX_DEFINITIONS_DIR, 62 | username=config.COUCHBASE_USER, 63 | password=config.COUCHBASE_PASSWORD, 64 | host=config.COUCHBASE_HOST, 65 | port=config.COUCHBASE_FULL_TEXT_PORT, 66 | ) 67 | logging.info("after ensure_create_full_text_indexes") 68 | logging.info("before ensure_create_couchbase_app_user sync") 69 | ensure_create_couchbase_user( 70 | cluster_url=cluster_url, 71 | username=config.COUCHBASE_USER, 72 | password=config.COUCHBASE_PASSWORD, 73 | new_user_id=config.COUCHBASE_SYNC_GATEWAY_USER, 74 | new_user_password=config.COUCHBASE_SYNC_GATEWAY_PASSWORD, 75 | ) 76 | logging.info("after ensure_create_couchbase_app_user sync") 77 | logging.info("before upsert_user first superuser") 78 | user_in = UserCreate( 79 | username=config.FIRST_SUPERUSER, 80 | password=config.FIRST_SUPERUSER_PASSWORD, 81 | email=config.FIRST_SUPERUSER, 82 | admin_roles=[RoleEnum.superuser], 83 | admin_channels=[config.FIRST_SUPERUSER], 84 | ) 85 | crud.user.upsert(bucket, user_in=user_in, persist_to=1) 86 | logging.info("after upsert_user first superuser") 87 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/email-templates/build/new_account.html: -------------------------------------------------------------------------------- 1 |

{{ project_name }} - New Account
You have a new account:
Username: {{ username }}
Password: {{ password }}
Go to Dashboard

-------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/email-templates/build/test_email.html: -------------------------------------------------------------------------------- 1 |

{{ project_name }}
Test email for: {{ email }}
-------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/email-templates/src/new_account.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ project_name }} - New Account 7 | You have a new account: 8 | Username: {{ username }} 9 | Password: {{ password }} 10 | Go to Dashboard 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/email-templates/src/reset_password.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ project_name }} - Password Recovery 7 | We received a request to recover the password for user {{ username }} 8 | with email {{ email }} 9 | Reset your password by clicking the button below: 10 | Reset Password 11 | Or open the following link: 12 | {{ link }} 13 | 14 | The reset password link / button will expire in {{ valid_hours }} hours. 15 | If you didn't request a password recovery you can disregard this email. 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/email-templates/src/test_email.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ project_name }} 7 | Test email for: {{ email }} 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/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 import config 6 | 7 | app = FastAPI(title=config.PROJECT_NAME, openapi_url="/api/v1/openapi.json") 8 | 9 | # CORS 10 | origins = [] 11 | 12 | # Set all CORS enabled origins 13 | if config.BACKEND_CORS_ORIGINS: 14 | origins_raw = config.BACKEND_CORS_ORIGINS.split(",") 15 | for origin in origins_raw: 16 | use_origin = origin.strip() 17 | origins.append(use_origin) 18 | app.add_middleware( 19 | CORSMiddleware, 20 | allow_origins=origins, 21 | allow_credentials=True, 22 | allow_methods=["*"], 23 | allow_headers=["*"], 24 | ), 25 | 26 | app.include_router(api_router, prefix=config.API_V1_STR) 27 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/backend/app/app/models/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/models/config.py: -------------------------------------------------------------------------------- 1 | USERPROFILE_DOC_TYPE = "userprofile" 2 | ITEM_DOC_TYPE = "item" 3 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/models/item.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from app.models.config import ITEM_DOC_TYPE 4 | 5 | 6 | # Shared properties 7 | class ItemBase(BaseModel): 8 | title: str = None 9 | description: str = None 10 | 11 | 12 | # Properties to receive on item creation 13 | class ItemCreate(ItemBase): 14 | title: str 15 | 16 | 17 | # Properties to receive on item update 18 | class ItemUpdate(ItemBase): 19 | pass 20 | 21 | 22 | # Properties to return to client 23 | class Item(ItemBase): 24 | id: str 25 | title: str 26 | owner_username: str 27 | 28 | 29 | # Properties properties stored in DB 30 | class ItemInDB(ItemBase): 31 | type: str = ITEM_DOC_TYPE 32 | id: str 33 | title: str 34 | owner_username: str 35 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/models/msg.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class Msg(BaseModel): 5 | msg: str 6 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/models/role.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import List 3 | 4 | from pydantic import BaseModel 5 | 6 | from app.core.config import ROLE_SUPERUSER 7 | 8 | 9 | class RoleEnum(Enum): 10 | superuser = ROLE_SUPERUSER 11 | 12 | 13 | class Roles(BaseModel): 14 | roles: List[RoleEnum] 15 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/models/token.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class Token(BaseModel): 5 | access_token: str 6 | token_type: str 7 | 8 | 9 | class TokenPayload(BaseModel): 10 | username: str = None 11 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/models/user.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Union 2 | 3 | from pydantic import BaseModel 4 | 5 | from app.models.config import USERPROFILE_DOC_TYPE 6 | from app.models.role import RoleEnum 7 | 8 | 9 | # Shared properties in Couchbase and Sync Gateway 10 | class UserBase(BaseModel): 11 | email: Optional[str] = None 12 | admin_roles: Optional[List[Union[str, RoleEnum]]] = None 13 | admin_channels: Optional[List[Union[str, RoleEnum]]] = None 14 | disabled: Optional[bool] = None 15 | 16 | 17 | # Shared properties in Couchbase 18 | class UserBaseInDB(UserBase): 19 | username: Optional[str] = None 20 | full_name: Optional[str] = None 21 | 22 | 23 | # Properties to receive via API on creation 24 | class UserCreate(UserBaseInDB): 25 | username: str 26 | password: str 27 | admin_roles: List[Union[str, RoleEnum]] = [] 28 | admin_channels: List[Union[str, RoleEnum]] = [] 29 | disabled: bool = False 30 | 31 | 32 | # Properties to receive via API on update 33 | class UserUpdate(UserBaseInDB): 34 | password: Optional[str] = None 35 | 36 | 37 | # Additional properties to return via API 38 | class User(UserBaseInDB): 39 | pass 40 | 41 | 42 | # Additional properties stored in DB 43 | class UserInDB(UserBaseInDB): 44 | type: str = USERPROFILE_DOC_TYPE 45 | hashed_password: str 46 | username: str 47 | 48 | 49 | # Additional properties in Sync Gateway 50 | class UserSyncIn(UserBase): 51 | name: str 52 | password: Optional[str] = None 53 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/search_index_definitions/items.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "items", 3 | "type": "fulltext-alias", 4 | "params": { 5 | "targets": { 6 | "items_01": {} 7 | } 8 | }, 9 | "sourceType": "nil", 10 | "sourceName": "", 11 | "sourceUUID": "", 12 | "sourceParams": null, 13 | "planParams": {}, 14 | "uuid": "" 15 | } -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/search_index_definitions/items_01.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "items_01", 3 | "type": "fulltext-index", 4 | "params": { 5 | "mapping": { 6 | "types": { 7 | "item": { 8 | "enabled": true, 9 | "dynamic": false, 10 | "properties": { 11 | "owner_username": { 12 | "enabled": true, 13 | "dynamic": false, 14 | "fields": [ 15 | { 16 | "name": "owner_username", 17 | "type": "text", 18 | "analyzer": "keyword", 19 | "store": false, 20 | "index": true, 21 | "include_term_vectors": true, 22 | "include_in_all": true 23 | } 24 | ] 25 | }, 26 | "description": { 27 | "enabled": true, 28 | "dynamic": false, 29 | "fields": [ 30 | { 31 | "name": "description", 32 | "type": "text", 33 | "store": false, 34 | "index": true, 35 | "include_term_vectors": true, 36 | "include_in_all": true 37 | } 38 | ] 39 | }, 40 | "title": { 41 | "enabled": true, 42 | "dynamic": false, 43 | "fields": [ 44 | { 45 | "name": "title", 46 | "type": "text", 47 | "store": false, 48 | "index": true, 49 | "include_term_vectors": true, 50 | "include_in_all": true 51 | } 52 | ] 53 | }, 54 | "type": { 55 | "enabled": true, 56 | "dynamic": false, 57 | "fields": [ 58 | { 59 | "name": "type", 60 | "type": "text", 61 | "store": false, 62 | "index": true, 63 | "include_term_vectors": false, 64 | "include_in_all": false 65 | } 66 | ] 67 | }, 68 | "id": { 69 | "enabled": true, 70 | "dynamic": false, 71 | "fields": [ 72 | { 73 | "name": "id", 74 | "type": "text", 75 | "store": false, 76 | "index": true, 77 | "include_term_vectors": false, 78 | "include_in_all": false 79 | } 80 | ] 81 | } 82 | } 83 | } 84 | }, 85 | "default_mapping": { 86 | "enabled": false, 87 | "dynamic": true 88 | }, 89 | "default_type": "_default", 90 | "default_analyzer": "standard", 91 | "default_datetime_parser": "dateTimeOptional", 92 | "default_field": "_all", 93 | "store_dynamic": false, 94 | "index_dynamic": true 95 | }, 96 | "store": { 97 | "indexType": "scorch", 98 | "kvStoreName": "" 99 | }, 100 | "doc_config": { 101 | "mode": "type_field", 102 | "type_field": "type", 103 | "docid_prefix_delim": "", 104 | "docid_regexp": "" 105 | } 106 | }, 107 | "sourceType": "couchbase", 108 | "sourceName": "app", 109 | "sourceUUID": "", 110 | "sourceParams": {}, 111 | "planParams": { 112 | "maxPartitionsPerPIndex": 171, 113 | "numReplicas": 0 114 | }, 115 | "uuid": "" 116 | } -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/search_index_definitions/users.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "users", 3 | "type": "fulltext-alias", 4 | "params": { 5 | "targets": { 6 | "users_01": {} 7 | } 8 | }, 9 | "sourceType": "nil", 10 | "sourceName": "", 11 | "sourceUUID": "", 12 | "sourceParams": null, 13 | "planParams": {}, 14 | "uuid": "" 15 | } 16 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/backend/app/app/tests/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{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/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{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 | import requests 2 | 3 | from app.core import config 4 | from app.tests.utils.utils import get_server_api 5 | 6 | 7 | def test_celery_worker_test(superuser_token_headers): 8 | server_api = get_server_api() 9 | data = {"msg": "test"} 10 | r = requests.post( 11 | f"{server_api}{config.API_V1_STR}/utils/test-celery/", 12 | json=data, 13 | headers=superuser_token_headers, 14 | ) 15 | response = r.json() 16 | assert response["msg"] == "Word received" 17 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_items.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from app.core import config 4 | from app.tests.utils.item import create_random_item 5 | from app.tests.utils.utils import get_server_api 6 | 7 | 8 | def test_create_item(superuser_token_headers): 9 | server_api = get_server_api() 10 | data = {"title": "Foo", "description": "Fighters"} 11 | response = requests.post( 12 | f"{server_api}{config.API_V1_STR}/items/", 13 | headers=superuser_token_headers, 14 | json=data, 15 | ) 16 | content = response.json() 17 | assert content["title"] == data["title"] 18 | assert content["description"] == data["description"] 19 | assert "id" in content 20 | assert "owner_username" in content 21 | 22 | 23 | def test_read_item(superuser_token_headers): 24 | item = create_random_item() 25 | server_api = get_server_api() 26 | response = requests.get( 27 | f"{server_api}{config.API_V1_STR}/items/{item.id}", 28 | headers=superuser_token_headers, 29 | ) 30 | content = response.json() 31 | assert content["title"] == item.title 32 | assert content["description"] == item.description 33 | assert content["id"] == item.id 34 | assert content["owner_username"] == item.owner_username 35 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_login.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from app.core import config 4 | from app.tests.utils.utils import get_server_api 5 | 6 | 7 | def test_get_access_token(): 8 | server_api = get_server_api() 9 | login_data = { 10 | "username": config.FIRST_SUPERUSER, 11 | "password": config.FIRST_SUPERUSER_PASSWORD, 12 | } 13 | r = requests.post( 14 | f"{server_api}{config.API_V1_STR}/login/access-token", data=login_data 15 | ) 16 | tokens = r.json() 17 | assert r.status_code == 200 18 | assert "access_token" in tokens 19 | assert tokens["access_token"] 20 | 21 | 22 | def test_use_access_token(superuser_token_headers): 23 | server_api = get_server_api() 24 | r = requests.post( 25 | f"{server_api}{config.API_V1_STR}/login/test-token", 26 | headers=superuser_token_headers, 27 | ) 28 | result = r.json() 29 | assert r.status_code == 200 30 | assert "username" in result 31 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/api/api_v1/test_users.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from app import crud 4 | from app.core import config 5 | from app.db.database import get_default_bucket 6 | from app.models.user import UserCreate 7 | from app.tests.utils.user import user_authentication_headers 8 | from app.tests.utils.utils import get_server_api, random_lower_string 9 | 10 | 11 | def test_get_users_superuser_me(superuser_token_headers): 12 | server_api = get_server_api() 13 | r = requests.get( 14 | f"{server_api}{config.API_V1_STR}/users/me", headers=superuser_token_headers 15 | ) 16 | current_user = r.json() 17 | assert current_user 18 | assert current_user["disabled"] is False 19 | assert "superuser" in current_user["admin_roles"] 20 | assert current_user["username"] == config.FIRST_SUPERUSER 21 | 22 | 23 | def test_get_users_normal_user_me(normal_user_token_headers): 24 | server_api = get_server_api() 25 | r = requests.get( 26 | f"{server_api}{config.API_V1_STR}/users/me", headers=normal_user_token_headers 27 | ) 28 | current_user = r.json() 29 | assert current_user 30 | assert current_user["disabled"] is False 31 | assert "superuser" not in current_user["admin_roles"] 32 | assert current_user["email"] == config.EMAIL_TEST_USER 33 | 34 | 35 | def test_create_user_new_email(superuser_token_headers): 36 | server_api = get_server_api() 37 | username = random_lower_string() 38 | password = random_lower_string() 39 | data = {"username": username, "password": password} 40 | r = requests.post( 41 | f"{server_api}{config.API_V1_STR}/users/", 42 | headers=superuser_token_headers, 43 | json=data, 44 | ) 45 | assert 200 <= r.status_code < 300 46 | created_user = r.json() 47 | bucket = get_default_bucket() 48 | user = crud.user.get(bucket, username=username) 49 | assert user.username == created_user["username"] 50 | 51 | 52 | def test_get_existing_user(superuser_token_headers): 53 | server_api = get_server_api() 54 | username = random_lower_string() 55 | password = random_lower_string() 56 | user_in = UserCreate(username=username, email=username, password=password) 57 | bucket = get_default_bucket() 58 | user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) 59 | r = requests.get( 60 | f"{server_api}{config.API_V1_STR}/users/{username}", 61 | headers=superuser_token_headers, 62 | ) 63 | assert 200 <= r.status_code < 300 64 | api_user = r.json() 65 | user = crud.user.get(bucket, username=username) 66 | assert user.username == api_user["username"] 67 | 68 | 69 | def test_create_user_existing_username(superuser_token_headers): 70 | server_api = get_server_api() 71 | username = random_lower_string() 72 | # username = email 73 | password = random_lower_string() 74 | user_in = UserCreate(username=username, email=username, password=password) 75 | bucket = get_default_bucket() 76 | user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) 77 | data = {"username": username, "password": password} 78 | r = requests.post( 79 | f"{server_api}{config.API_V1_STR}/users/", 80 | headers=superuser_token_headers, 81 | json=data, 82 | ) 83 | created_user = r.json() 84 | assert r.status_code == 400 85 | assert "_id" not in created_user 86 | 87 | 88 | def test_create_user_by_normal_user(normal_user_token_headers): 89 | server_api = get_server_api() 90 | username = random_lower_string() 91 | password = random_lower_string() 92 | data = {"username": username, "password": password} 93 | r = requests.post( 94 | f"{server_api}{config.API_V1_STR}/users/", headers=normal_user_token_headers, json=data 95 | ) 96 | assert r.status_code == 400 97 | 98 | 99 | def test_retrieve_users(superuser_token_headers): 100 | server_api = get_server_api() 101 | username = random_lower_string() 102 | password = random_lower_string() 103 | user_in = UserCreate(username=username, email=username, password=password) 104 | bucket = get_default_bucket() 105 | user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) 106 | 107 | username2 = random_lower_string() 108 | password2 = random_lower_string() 109 | user_in2 = UserCreate(username=username2, email=username2, password=password2) 110 | user2 = crud.user.upsert(bucket, user_in=user_in, persist_to=1) 111 | 112 | r = requests.get( 113 | f"{server_api}{config.API_V1_STR}/users/", headers=superuser_token_headers 114 | ) 115 | all_users = r.json() 116 | 117 | assert len(all_users) > 1 118 | for user in all_users: 119 | assert "username" in user 120 | assert "admin_roles" in user 121 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.core import config 4 | from app.tests.utils.utils import get_server_api, get_superuser_token_headers 5 | from app.tests.utils.user import authentication_token_from_email 6 | 7 | 8 | @pytest.fixture(scope="module") 9 | def server_api(): 10 | return get_server_api() 11 | 12 | 13 | @pytest.fixture(scope="module") 14 | def superuser_token_headers(): 15 | return get_superuser_token_headers() 16 | 17 | 18 | @pytest.fixture(scope="module") 19 | def normal_user_token_headers(): 20 | return authentication_token_from_email(config.EMAIL_TEST_USER) 21 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/crud/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/backend/app/app/tests/crud/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_get_ids.py: -------------------------------------------------------------------------------- 1 | from app import crud 2 | 3 | 4 | def test_get_user_id(): 5 | username = "johndoe@example.com" 6 | user_id = crud.user.get_doc_id(username) 7 | assert user_id == "userprofile::johndoe@example.com" 8 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_item.py: -------------------------------------------------------------------------------- 1 | from app import crud 2 | from app.db.database import get_default_bucket 3 | from app.models.config import ITEM_DOC_TYPE 4 | from app.models.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(): 10 | title = random_lower_string() 11 | description = random_lower_string() 12 | id = crud.utils.generate_new_id() 13 | item_in = ItemCreate(title=title, description=description) 14 | bucket = get_default_bucket() 15 | user = create_random_user() 16 | item = crud.item.upsert( 17 | bucket=bucket, id=id, doc_in=item_in, owner_username=user.username, persist_to=1 18 | ) 19 | assert item.id == id 20 | assert item.type == ITEM_DOC_TYPE 21 | assert item.title == title 22 | assert item.description == description 23 | assert item.owner_username == user.username 24 | 25 | 26 | def test_get_item(): 27 | title = random_lower_string() 28 | description = random_lower_string() 29 | id = crud.utils.generate_new_id() 30 | item_in = ItemCreate(title=title, description=description) 31 | bucket = get_default_bucket() 32 | user = create_random_user() 33 | item = crud.item.upsert( 34 | bucket=bucket, id=id, doc_in=item_in, owner_username=user.username, persist_to=1 35 | ) 36 | stored_item = crud.item.get(bucket=bucket, id=id) 37 | assert item.id == stored_item.id 38 | assert item.title == stored_item.title 39 | assert item.description == stored_item.description 40 | assert item.owner_username == stored_item.owner_username 41 | 42 | 43 | def test_update_item(): 44 | title = random_lower_string() 45 | description = random_lower_string() 46 | id = crud.utils.generate_new_id() 47 | item_in = ItemCreate(title=title, description=description) 48 | bucket = get_default_bucket() 49 | user = create_random_user() 50 | item = crud.item.upsert( 51 | bucket=bucket, id=id, doc_in=item_in, owner_username=user.username, persist_to=1 52 | ) 53 | description2 = random_lower_string() 54 | item_update = ItemUpdate(description=description2) 55 | item2 = crud.item.update( 56 | bucket=bucket, 57 | id=id, 58 | doc_in=item_update, 59 | owner_username=item.owner_username, 60 | persist_to=1, 61 | ) 62 | assert item.id == item2.id 63 | assert item.title == item2.title 64 | assert item.description == description 65 | assert item2.description == description2 66 | assert item.owner_username == item2.owner_username 67 | 68 | 69 | def test_delete_item(): 70 | title = random_lower_string() 71 | description = random_lower_string() 72 | id = crud.utils.generate_new_id() 73 | item_in = ItemCreate(title=title, description=description) 74 | bucket = get_default_bucket() 75 | user = create_random_user() 76 | item = crud.item.upsert( 77 | bucket=bucket, id=id, doc_in=item_in, owner_username=user.username, persist_to=1 78 | ) 79 | item2 = crud.item.remove(bucket=bucket, id=id, persist_to=1) 80 | item3 = crud.item.get(bucket=bucket, id=id) 81 | assert item3 is None 82 | assert item2.id == id 83 | assert item2.title == title 84 | assert item2.description == description 85 | assert item2.owner_username == user.username 86 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/crud/test_user.py: -------------------------------------------------------------------------------- 1 | from fastapi.encoders import jsonable_encoder 2 | 3 | from app import crud 4 | from app.db.database import get_default_bucket 5 | from app.models.role import RoleEnum 6 | from app.models.user import UserCreate 7 | from app.tests.utils.utils import random_lower_string 8 | 9 | 10 | def test_create_user(): 11 | email = random_lower_string() 12 | password = random_lower_string() 13 | user_in = UserCreate(username=email, email=email, password=password) 14 | bucket = get_default_bucket() 15 | user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) 16 | assert hasattr(user, "username") 17 | assert user.username == email 18 | assert hasattr(user, "hashed_password") 19 | assert hasattr(user, "type") 20 | assert user.type == "userprofile" 21 | 22 | 23 | def test_authenticate_user(): 24 | email = random_lower_string() 25 | password = random_lower_string() 26 | user_in = UserCreate(username=email, email=email, password=password) 27 | bucket = get_default_bucket() 28 | user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) 29 | authenticated_user = crud.user.authenticate( 30 | bucket, username=user_in.username, password=password 31 | ) 32 | assert authenticated_user 33 | assert user.username == authenticated_user.username 34 | 35 | 36 | def test_not_authenticate_user(): 37 | email = random_lower_string() 38 | password = random_lower_string() 39 | bucket = get_default_bucket() 40 | user = crud.user.authenticate(bucket, username=email, password=password) 41 | assert user is None 42 | 43 | 44 | def test_check_if_user_is_active(): 45 | email = random_lower_string() 46 | password = random_lower_string() 47 | user_in = UserCreate(username=email, email=email, password=password) 48 | bucket = get_default_bucket() 49 | user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) 50 | is_active = crud.user.is_active(user) 51 | assert is_active is True 52 | 53 | 54 | def test_check_if_user_is_active_inactive(): 55 | email = random_lower_string() 56 | password = random_lower_string() 57 | user_in = UserCreate(username=email, email=email, password=password, disabled=True) 58 | bucket = get_default_bucket() 59 | user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) 60 | is_active = crud.user.is_active(user) 61 | assert is_active is False 62 | 63 | 64 | def test_check_if_user_is_superuser(): 65 | email = random_lower_string() 66 | password = random_lower_string() 67 | user_in = UserCreate( 68 | username=email, email=email, password=password, admin_roles=[RoleEnum.superuser] 69 | ) 70 | bucket = get_default_bucket() 71 | user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) 72 | is_superuser = crud.user.is_superuser(user) 73 | assert is_superuser is True 74 | 75 | 76 | def test_check_if_user_is_superuser_normal_user(): 77 | username = random_lower_string() 78 | password = random_lower_string() 79 | user_in = UserCreate(username=username, email=username, password=password) 80 | bucket = get_default_bucket() 81 | user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) 82 | is_superuser = crud.user.is_superuser(user) 83 | assert is_superuser is False 84 | 85 | 86 | def test_get_user(): 87 | password = random_lower_string() 88 | username = random_lower_string() 89 | user_in = UserCreate( 90 | username=username, 91 | email=username, 92 | password=password, 93 | admin_roles=[RoleEnum.superuser], 94 | ) 95 | bucket = get_default_bucket() 96 | user = crud.user.upsert(bucket=bucket, user_in=user_in, persist_to=1) 97 | user_2 = crud.user.get(bucket, username=username) 98 | assert user.username == user_2.username 99 | assert jsonable_encoder(user) == jsonable_encoder(user_2) 100 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/backend/app/app/tests/utils/__init__.py -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/utils/item.py: -------------------------------------------------------------------------------- 1 | from app import crud 2 | from app.db.database import get_default_bucket 3 | from app.models.item import ItemCreate 4 | from app.tests.utils.user import create_random_user 5 | from app.tests.utils.utils import random_lower_string 6 | 7 | 8 | def create_random_item(owner_username: str = None): 9 | if owner_username is None: 10 | user = create_random_user() 11 | owner_username = user.username 12 | title = random_lower_string() 13 | description = random_lower_string() 14 | id = crud.utils.generate_new_id() 15 | item_in = ItemCreate(title=title, description=description, id=id) 16 | bucket = get_default_bucket() 17 | return crud.item.upsert( 18 | bucket=bucket, 19 | id=id, 20 | doc_in=item_in, 21 | owner_username=owner_username, 22 | persist_to=1, 23 | ) 24 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/utils/user.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from app import crud 4 | from app.core import config 5 | from app.db.database import get_default_bucket 6 | from app.models.user import UserCreate, UserUpdate 7 | from app.tests.utils.utils import random_lower_string, get_server_api 8 | 9 | 10 | def user_authentication_headers(server_api, email, password): 11 | data = {"username": email, "password": password} 12 | 13 | r = requests.post(f"{server_api}{config.API_V1_STR}/login/access-token", data=data) 14 | response = r.json() 15 | auth_token = response["access_token"] 16 | headers = {"Authorization": f"Bearer {auth_token}"} 17 | return headers 18 | 19 | 20 | def create_random_user(): 21 | email = random_lower_string() 22 | password = random_lower_string() 23 | user_in = UserCreate(username=email, email=email, password=password) 24 | bucket = get_default_bucket() 25 | user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) 26 | return user 27 | 28 | 29 | def authentication_token_from_email(email): 30 | """ 31 | Return a valid token for the user with given email. 32 | 33 | If the user doesn't exist it is created first. 34 | """ 35 | password = random_lower_string() 36 | bucket = get_default_bucket() 37 | 38 | user = crud.user.get_by_email(bucket, email=email) 39 | if not user: 40 | user_in = UserCreate(username=email, email=email, password=password) 41 | user = crud.user.upsert(bucket, user_in=user_in, persist_to=1) 42 | else: 43 | user_in = UserUpdate(password=password) 44 | user = crud.user.update(bucket, user=user, user_in=user_in) 45 | 46 | return user_authentication_headers(get_server_api(), email, password) 47 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests/utils/utils.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | import requests 5 | 6 | from app.core import config 7 | 8 | 9 | def random_lower_string(): 10 | return "".join(random.choices(string.ascii_lowercase, k=32)) 11 | 12 | 13 | def get_server_api(): 14 | server_name = f"http://{config.SERVER_NAME}" 15 | return server_name 16 | 17 | 18 | def get_superuser_token_headers(): 19 | server_api = get_server_api() 20 | login_data = { 21 | "username": config.FIRST_SUPERUSER, 22 | "password": config.FIRST_SUPERUSER_PASSWORD, 23 | } 24 | r = requests.post( 25 | f"{server_api}{config.API_V1_STR}/login/access-token", data=login_data 26 | ) 27 | tokens = r.json() 28 | a_token = tokens["access_token"] 29 | headers = {"Authorization": f"Bearer {a_token}"} 30 | # superuser_token_headers = headers 31 | return headers 32 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/tests_pre_start.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed 4 | 5 | from app.db.database import get_default_bucket 6 | from app.tests.api.api_v1.test_login import test_get_access_token 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(): 22 | try: 23 | # Check Couchbase is awake 24 | bucket = get_default_bucket() 25 | logger.info( 26 | f"Database bucket connection established with bucket object: {bucket}" 27 | ) 28 | 29 | # Wait for API to be awake, run one simple tests to authenticate 30 | test_get_access_token() 31 | except Exception as e: 32 | logger.error(e) 33 | raise e 34 | 35 | 36 | def main(): 37 | logger.info("Initializing service") 38 | init() 39 | logger.info("Service finished initializing") 40 | 41 | 42 | if __name__ == "__main__": 43 | main() 44 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime, timedelta 3 | from pathlib import Path 4 | from typing import Optional 5 | 6 | import emails 7 | import jwt 8 | from emails.template import JinjaTemplate 9 | from jwt.exceptions import InvalidTokenError 10 | 11 | from app.core import config 12 | 13 | password_reset_jwt_subject = "reset" 14 | 15 | 16 | def send_email(email_to: str, subject_template="", html_template="", environment={}): 17 | assert config.EMAILS_ENABLED, "no provided configuration for email variables" 18 | message = emails.Message( 19 | subject=JinjaTemplate(subject_template), 20 | html=JinjaTemplate(html_template), 21 | mail_from=(config.EMAILS_FROM_NAME, config.EMAILS_FROM_EMAIL), 22 | ) 23 | smtp_options = {"host": config.SMTP_HOST, "port": config.SMTP_PORT} 24 | if config.SMTP_TLS: 25 | smtp_options["tls"] = True 26 | if config.SMTP_USER: 27 | smtp_options["user"] = config.SMTP_USER 28 | if config.SMTP_PASSWORD: 29 | smtp_options["password"] = config.SMTP_PASSWORD 30 | response = message.send(to=email_to, render=environment, smtp=smtp_options) 31 | logging.info(f"send email result: {response}") 32 | 33 | 34 | def send_test_email(email_to: str): 35 | project_name = config.PROJECT_NAME 36 | subject = f"{project_name} - Test email" 37 | with open(Path(config.EMAIL_TEMPLATES_DIR) / "test_email.html") as f: 38 | template_str = f.read() 39 | send_email( 40 | email_to=email_to, 41 | subject_template=subject, 42 | html_template=template_str, 43 | environment={"project_name": config.PROJECT_NAME, "email": email_to}, 44 | ) 45 | 46 | 47 | def send_reset_password_email(email_to: str, username: str, token: str): 48 | project_name = config.PROJECT_NAME 49 | subject = f"{project_name} - Password recovery for user {username}" 50 | with open(Path(config.EMAIL_TEMPLATES_DIR) / "reset_password.html") as f: 51 | template_str = f.read() 52 | if hasattr(token, "decode"): 53 | use_token = token.decode() 54 | else: 55 | use_token = token 56 | server_host = config.SERVER_HOST 57 | link = f"{server_host}/reset-password?token={use_token}" 58 | send_email( 59 | email_to=email_to, 60 | subject_template=subject, 61 | html_template=template_str, 62 | environment={ 63 | "project_name": config.PROJECT_NAME, 64 | "username": username, 65 | "email": email_to, 66 | "valid_hours": config.EMAIL_RESET_TOKEN_EXPIRE_HOURS, 67 | "link": link, 68 | }, 69 | ) 70 | 71 | 72 | def send_new_account_email(email_to: str, username: str, password: str): 73 | project_name = config.PROJECT_NAME 74 | subject = f"{project_name} - New account for user {username}" 75 | with open(Path(config.EMAIL_TEMPLATES_DIR) / "new_account.html") as f: 76 | template_str = f.read() 77 | link = config.SERVER_HOST 78 | send_email( 79 | email_to=email_to, 80 | subject_template=subject, 81 | html_template=template_str, 82 | environment={ 83 | "project_name": config.PROJECT_NAME, 84 | "username": username, 85 | "password": password, 86 | "email": email_to, 87 | "link": link, 88 | }, 89 | ) 90 | 91 | 92 | def generate_password_reset_token(username): 93 | delta = timedelta(hours=config.EMAIL_RESET_TOKEN_EXPIRE_HOURS) 94 | now = datetime.utcnow() 95 | expires = now + delta 96 | exp = expires.timestamp() 97 | encoded_jwt = jwt.encode( 98 | { 99 | "exp": exp, 100 | "nbf": now, 101 | "sub": password_reset_jwt_subject, 102 | "username": username, 103 | }, 104 | config.SECRET_KEY, 105 | algorithm="HS256", 106 | ) 107 | return encoded_jwt 108 | 109 | 110 | def verify_password_reset_token(token) -> Optional[str]: 111 | try: 112 | decoded_token = jwt.decode(token, config.SECRET_KEY, algorithms=["HS256"]) 113 | assert decoded_token["sub"] == password_reset_jwt_subject 114 | return decoded_token["username"] 115 | except InvalidTokenError: 116 | return None 117 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/app/worker.py: -------------------------------------------------------------------------------- 1 | from raven import Client 2 | 3 | from app.core import config 4 | from app.core.celery_app import celery_app 5 | 6 | client_sentry = Client(config.SENTRY_DSN) 7 | 8 | 9 | @celery_app.task(acks_late=True) 10 | def test_celery(word: str): 11 | return f"test task return {word}" 12 | -------------------------------------------------------------------------------- /{{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 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -x 4 | 5 | autoflake --remove-all-unused-imports --recursive --remove-unused-variables --in-place app --exclude=__init__.py 6 | isort --multi-line=3 --trailing-comma --force-grid-wrap=0 --combine-as --line-width 88 --recursive --apply app 7 | black app 8 | vulture app --min-confidence 70 9 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/tests-start.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | set -e 3 | 4 | python /app/app/tests_pre_start.py 5 | 6 | pytest $* /app/app/tests/ 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/app/worker-start.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | set -e 3 | 4 | python /app/app/celeryworker_pre_start.py 5 | 6 | celery worker -A app.worker -l info -Q main-queue -c 1 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/backend.dockerfile: -------------------------------------------------------------------------------- 1 | FROM tiangolo/uvicorn-gunicorn-fastapi:python3.6 2 | 3 | # Dependencies for Couchbase 4 | RUN wget -O - http://packages.couchbase.com/ubuntu/couchbase.key | apt-key add - && \ 5 | OS_CODENAME=`cat /etc/os-release | grep VERSION_CODENAME | cut -f2 -d=` && \ 6 | echo "deb http://packages.couchbase.com/ubuntu ${OS_CODENAME} ${OS_CODENAME}/main" > /etc/apt/sources.list.d/couchbase.list && \ 7 | apt-get update && apt-get install -y libcouchbase-dev libcouchbase2-bin build-essential 8 | 9 | RUN pip install \ 10 | celery~=4.3 \ 11 | passlib[bcrypt] \ 12 | tenacity \ 13 | requests \ 14 | couchbase \ 15 | emails \ 16 | "fastapi>=0.16.0" \ 17 | uvicorn \ 18 | gunicorn \ 19 | pyjwt \ 20 | python-multipart \ 21 | email_validator \ 22 | jinja2 23 | 24 | # For development, Jupyter remote kernel, Hydrogen 25 | # Using inside the container: 26 | # jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888 27 | ARG env=prod 28 | RUN bash -c "if [ $env == 'dev' ] ; then pip install jupyterlab ; fi" 29 | EXPOSE 8888 30 | 31 | COPY ./app /app 32 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/celeryworker.dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6 2 | 3 | # Dependencies for Couchbase 4 | RUN wget -O - http://packages.couchbase.com/ubuntu/couchbase.key | apt-key add - 5 | RUN echo "deb http://packages.couchbase.com/ubuntu stretch stretch/main" > /etc/apt/sources.list.d/couchbase.list 6 | RUN apt-get update && apt-get install -y libcouchbase-dev build-essential 7 | 8 | RUN pip install raven celery~=4.3 passlib[bcrypt] tenacity requests "fastapi>=0.16.0" couchbase emails pyjwt email_validator jinja2 9 | 10 | # For development, Jupyter remote kernel, Hydrogen 11 | # Using inside the container: 12 | # jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888 13 | ARG env=prod 14 | RUN bash -c "if [ $env == 'dev' ] ; then pip install jupyterlab ; fi" 15 | EXPOSE 8888 16 | 17 | ENV C_FORCE_ROOT=1 18 | 19 | COPY ./app /app 20 | WORKDIR /app 21 | 22 | ENV PYTHONPATH=/app 23 | 24 | COPY ./app/worker-start.sh /worker-start.sh 25 | 26 | RUN chmod +x /worker-start.sh 27 | 28 | CMD ["bash", "/worker-start.sh"] 29 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/backend/tests.dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6 2 | 3 | RUN wget -O - http://packages.couchbase.com/ubuntu/couchbase.key | apt-key add - && \ 4 | OS_CODENAME=`cat /etc/os-release | grep VERSION_CODENAME | cut -f2 -d=` && \ 5 | echo "deb http://packages.couchbase.com/ubuntu ${OS_CODENAME} ${OS_CODENAME}/main" > /etc/apt/sources.list.d/couchbase.list && \ 6 | apt-get update && apt-get install -y libcouchbase-dev build-essential 7 | 8 | RUN pip install requests pytest tenacity passlib[bcrypt] couchbase "fastapi>=0.16.0" 9 | 10 | # For development, Jupyter remote kernel, Hydrogen 11 | # Using inside the container: 12 | # jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888 13 | ARG env=prod 14 | RUN bash -c "if [ $env == 'dev' ] ; then pip install jupyterlab ; fi" 15 | EXPOSE 8888 16 | 17 | COPY ./app /app 18 | 19 | ENV PYTHONPATH=/app 20 | 21 | COPY ./app/tests-start.sh /tests-start.sh 22 | 23 | RUN chmod +x /tests-start.sh 24 | 25 | # This will make the container wait, doing nothing, but alive 26 | CMD ["bash", "-c", "while true; do sleep 1; done"] 27 | 28 | # Afterwards you can exec a command /tests-start.sh in the live container, like: 29 | # docker exec -it backend-tests /tests-start.sh 30 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/cookiecutter-config-file.yml: -------------------------------------------------------------------------------- 1 | default_context: 2 | project_name: '{{ cookiecutter.project_name }}' 3 | project_slug: '{{ cookiecutter.project_slug }}' 4 | domain_main: '{{ cookiecutter.domain_main }}' 5 | domain_staging: '{{ cookiecutter.domain_staging }}' 6 | docker_swarm_stack_name_main: '{{ cookiecutter.docker_swarm_stack_name_main }}' 7 | docker_swarm_stack_name_staging: '{{ cookiecutter.docker_swarm_stack_name_staging }}' 8 | secret_key: '{{ cookiecutter.secret_key }}' 9 | first_superuser: '{{ cookiecutter.first_superuser }}' 10 | first_superuser_password: '{{ cookiecutter.first_superuser_password }}' 11 | backend_cors_origins: '{{ cookiecutter.backend_cors_origins }}' 12 | smtp_port: '{{ cookiecutter.smtp_port }}' 13 | smtp_host: '{{ cookiecutter.smtp_host }}' 14 | smtp_user: '{{ cookiecutter.smtp_user }}' 15 | smtp_password: '{{ cookiecutter.smtp_password }}' 16 | smtp_emails_from_email: '{{ cookiecutter.smtp_emails_from_email }}' 17 | couchbase_user: '{{ cookiecutter.couchbase_user }}' 18 | couchbase_password: '{{ cookiecutter.couchbase_password }}' 19 | couchbase_sync_gateway_cors: '{{ cookiecutter.couchbase_sync_gateway_cors }}' 20 | couchbase_sync_gateway_user: '{{ cookiecutter.couchbase_sync_gateway_user }}' 21 | couchbase_sync_gateway_password: '{{ cookiecutter.couchbase_sync_gateway_password }}' 22 | traefik_constraint_tag: '{{ cookiecutter.traefik_constraint_tag }}' 23 | traefik_constraint_tag_staging: '{{ cookiecutter.traefik_constraint_tag_staging }}' 24 | traefik_public_network: '{{ cookiecutter.traefik_public_network }}' 25 | traefik_public_constraint_tag: '{{ cookiecutter.traefik_public_constraint_tag }}' 26 | flower_auth: '{{ cookiecutter.flower_auth }}' 27 | sentry_dsn: '{{ cookiecutter.sentry_dsn }}' 28 | docker_image_prefix: '{{ cookiecutter.docker_image_prefix }}' 29 | docker_image_backend: '{{ cookiecutter.docker_image_backend }}' 30 | docker_image_celeryworker: '{{ cookiecutter.docker_image_celeryworker }}' 31 | docker_image_frontend: '{{ cookiecutter.docker_image_frontend }}' 32 | docker_image_sync_gateway: '{{ cookiecutter.docker_image_sync_gateway }}' 33 | _copy_without_render: [frontend/src/**/*.html, frontend/src/**/*.vue, frontend/node_modules/*, backend/app/app/email-templates/**] 34 | _template: ./ 35 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/docker-compose.deploy.build.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | backend: 4 | build: 5 | context: ./backend 6 | dockerfile: backend.dockerfile 7 | celeryworker: 8 | build: 9 | context: ./backend 10 | dockerfile: celeryworker.dockerfile 11 | frontend: 12 | build: 13 | context: ./frontend 14 | args: 15 | FRONTEND_ENV: ${FRONTEND_ENV-production} 16 | sync-gateway: 17 | build: 18 | context: ./sync-gateway 19 | dockerfile: Dockerfile 20 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/docker-compose.deploy.command.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | proxy: 4 | command: --docker \ 5 | --docker.swarmmode \ 6 | --docker.watch \ 7 | --docker.exposedbydefault=false \ 8 | --constraints=tag==${TRAEFIK_TAG} \ 9 | --logLevel=INFO \ 10 | --accessLog \ 11 | --web 12 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/docker-compose.deploy.images.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | backend: 4 | image: '${DOCKER_IMAGE_BACKEND}:${TAG-latest}' 5 | celeryworker: 6 | image: '${DOCKER_IMAGE_CELERYWORKER}:${TAG-latest}' 7 | frontend: 8 | image: '${DOCKER_IMAGE_FRONTEND}:${TAG-latest}' 9 | sync-gateway: 10 | image: '${DOCKER_IMAGE_SYNC_GATEWAY}:${TAG-latest}' 11 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/docker-compose.deploy.labels.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | couchbase: 4 | deploy: 5 | labels: 6 | - traefik.frontend.rule=Host:db.${DOMAIN} 7 | - traefik.enable=true 8 | - traefik.port=8091 9 | - traefik.tags=${TRAEFIK_PUBLIC_TAG} 10 | - traefik.docker.network=${TRAEFIK_PUBLIC_NETWORK} 11 | - traefik.frontend.entryPoints=http,https 12 | - traefik.frontend.redirect.entryPoint=https 13 | sync-gateway: 14 | deploy: 15 | labels: 16 | - traefik.frontend.rule=Host:sync.${DOMAIN} 17 | - traefik.enable=true 18 | - traefik.port=4984 19 | - traefik.tags=${TRAEFIK_PUBLIC_TAG} 20 | - traefik.docker.network=${TRAEFIK_PUBLIC_NETWORK} 21 | - traefik.frontend.entryPoints=http,https 22 | - traefik.frontend.redirect.entryPoint=https 23 | proxy: 24 | deploy: 25 | labels: 26 | # For the configured domain 27 | - traefik.frontend.rule=Host:${DOMAIN} 28 | # For a domain with and without 'www' 29 | # Comment the previous line above and un-comment the line below 30 | # - "traefik.frontend.rule=Host:www.${DOMAIN},${DOMAIN}" 31 | - traefik.enable=true 32 | - traefik.port=80 33 | - traefik.tags=${TRAEFIK_PUBLIC_TAG} 34 | - traefik.docker.network=${TRAEFIK_PUBLIC_NETWORK} 35 | - traefik.frontend.entryPoints=http,https 36 | - traefik.frontend.redirect.entryPoint=https 37 | # Uncomment the config line below to detect and redirect www to non-www (or the contrary) 38 | # The lines above for traefik.frontend.rule are needed too 39 | # - "traefik.frontend.redirect.regex=^https?://(www.)?(${DOMAIN})/(.*)" 40 | # To redirect from non-www to www un-comment the line below 41 | # - "traefik.frontend.redirect.replacement=https://www.${DOMAIN}/$$3" 42 | # To redirect from www to non-www un-comment the line below 43 | # - "traefik.frontend.redirect.replacement=https://${DOMAIN}/$$3" 44 | flower: 45 | deploy: 46 | labels: 47 | - traefik.frontend.rule=Host:flower.${DOMAIN} 48 | - traefik.enable=true 49 | - traefik.port=5555 50 | - traefik.tags=${TRAEFIK_PUBLIC_TAG} 51 | - traefik.docker.network=${TRAEFIK_PUBLIC_NETWORK} 52 | - traefik.frontend.entryPoints=http,https 53 | - traefik.frontend.redirect.entryPoint=https 54 | backend: 55 | deploy: 56 | labels: 57 | - traefik.frontend.rule=PathPrefix:/api,/docs,/redoc 58 | - traefik.enable=true 59 | - traefik.port=80 60 | - traefik.tags=${TRAEFIK_TAG} 61 | frontend: 62 | deploy: 63 | labels: 64 | - traefik.frontend.rule=PathPrefix:/ 65 | - traefik.enable=true 66 | - traefik.port=80 67 | - traefik.tags=${TRAEFIK_TAG} 68 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/docker-compose.deploy.networks.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | couchbase: 4 | networks: 5 | - ${TRAEFIK_PUBLIC_NETWORK} 6 | - default 7 | sync-gateway: 8 | networks: 9 | - ${TRAEFIK_PUBLIC_NETWORK} 10 | - default 11 | proxy: 12 | networks: 13 | - ${TRAEFIK_PUBLIC_NETWORK} 14 | - default 15 | flower: 16 | networks: 17 | - ${TRAEFIK_PUBLIC_NETWORK} 18 | - default 19 | 20 | networks: 21 | {{cookiecutter.traefik_public_network}}: 22 | external: true 23 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/docker-compose.deploy.volumes-placement.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | couchbase: 4 | volumes: 5 | - app-couchbase-data:/opt/couchbase/var 6 | deploy: 7 | placement: 8 | constraints: 9 | - node.labels.${STACK_NAME}.app-couchbase-data == true 10 | proxy: 11 | deploy: 12 | placement: 13 | constraints: 14 | - node.role == manager 15 | 16 | volumes: 17 | app-couchbase-data: 18 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/docker-compose.dev.build.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | backend: 4 | build: 5 | context: ./backend 6 | dockerfile: backend.dockerfile 7 | args: 8 | env: dev 9 | celeryworker: 10 | build: 11 | context: ./backend 12 | dockerfile: celeryworker.dockerfile 13 | args: 14 | env: dev 15 | backend-tests: 16 | build: 17 | context: ./backend 18 | dockerfile: tests.dockerfile 19 | args: 20 | env: dev 21 | frontend: 22 | build: 23 | context: ./frontend 24 | args: 25 | FRONTEND_ENV: dev 26 | sync-gateway: 27 | build: 28 | context: ./sync-gateway 29 | dockerfile: Dockerfile 30 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/docker-compose.dev.command.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | proxy: 4 | command: --docker \ 5 | --docker.watch \ 6 | --docker.exposedbydefault=false \ 7 | --constraints=tag==${TRAEFIK_TAG} \ 8 | --logLevel=DEBUG \ 9 | --accessLog \ 10 | --web 11 | # backend: 12 | # command: bash -c "while true; do sleep 1; done" # Infinite loop to keep container live doing nothing 13 | backend: 14 | command: /start-reload.sh 15 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/docker-compose.dev.env.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | backend: 4 | environment: 5 | - JUPYTER=jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888 6 | - SERVER_HOST=http://${DOMAIN} 7 | celeryworker: 8 | environment: 9 | - RUN=celery worker -A app.worker -l info -Q main-queue -c 1 10 | - JUPYTER=jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888 11 | - SERVER_HOST=http://${DOMAIN} 12 | backend-tests: 13 | environment: 14 | - JUPYTER=jupyter lab --ip=0.0.0.0 --allow-root --NotebookApp.custom_display_url=http://127.0.0.1:8888 15 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/docker-compose.dev.labels.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | proxy: 4 | labels: 5 | - traefik.frontend.rule=Host:${DOMAIN} 6 | - traefik.enable=true 7 | - traefik.port=80 8 | backend: 9 | labels: 10 | - traefik.frontend.rule=PathPrefix:/api,/docs,/redoc 11 | - traefik.enable=true 12 | - traefik.port=80 13 | - traefik.tags=${TRAEFIK_TAG} 14 | frontend: 15 | labels: 16 | - traefik.frontend.rule=PathPrefix:/ 17 | - traefik.enable=true 18 | - traefik.port=80 19 | - traefik.tags=${TRAEFIK_TAG} 20 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/docker-compose.dev.networks.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | backend: 4 | networks: 5 | default: 6 | aliases: 7 | - ${DOMAIN} 8 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/docker-compose.dev.ports.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | couchbase: 4 | ports: 5 | - '8091:8091' 6 | sync-gateway: 7 | ports: 8 | - '4984:4984' 9 | - '4985:4985' 10 | proxy: 11 | ports: 12 | - '80:80' 13 | - '8090:8080' 14 | flower: 15 | ports: 16 | - '5555:5555' 17 | backend: 18 | ports: 19 | - '8888:8888' 20 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/docker-compose.dev.volumes.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | backend: 4 | volumes: 5 | - ./backend/app:/app 6 | celeryworker: 7 | volumes: 8 | - ./backend/app:/app 9 | backend-tests: 10 | volumes: 11 | - ./backend/app:/app 12 | sync-gateway: 13 | volumes: 14 | - ./sync-gateway/sync:/sync 15 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/docker-compose.shared.admin.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | proxy: 4 | image: traefik:v1.7 5 | volumes: 6 | - /var/run/docker.sock:/var/run/docker.sock 7 | flower: 8 | image: mher/flower 9 | env_file: 10 | - env-flower.env 11 | command: 12 | - "--broker=amqp://guest@queue:5672//" 13 | # For the "Broker" tab to work in the flower UI, uncomment the following command argument, 14 | # and change the queue service's image as described in docker-compose.shared.base-images.yml 15 | # - "--broker_api=http://guest:guest@queue:15672/api//" 16 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/docker-compose.shared.base-images.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | couchbase: 4 | image: couchbase:community-6.0.0 5 | queue: 6 | image: rabbitmq:3 7 | # Using the below image instead is required to enable the "Broker" tab in the flower UI: 8 | # image: rabbitmq:3-management 9 | # 10 | # You also have to change the flower command as documented in docker-compose.shared.admin.yml 11 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/docker-compose.shared.depends.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | backend: 4 | depends_on: 5 | - couchbase 6 | celeryworker: 7 | depends_on: 8 | - couchbase 9 | - queue 10 | sync-gateway: 11 | depends_on: 12 | - couchbase 13 | - backend 14 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/docker-compose.shared.env.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | backend: 4 | env_file: 5 | - env-couchbase.env 6 | - env-sync-gateway.env 7 | - env-backend.env 8 | environment: 9 | - SERVER_NAME=${DOMAIN} 10 | - SERVER_HOST=https://${DOMAIN} 11 | celeryworker: 12 | env_file: 13 | - env-couchbase.env 14 | - env-sync-gateway.env 15 | - env-backend.env 16 | environment: 17 | - SERVER_HOST=https://${DOMAIN} 18 | sync-gateway: 19 | env_file: 20 | - env-couchbase.env 21 | - env-sync-gateway.env 22 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | backend-tests: 4 | build: 5 | context: ./backend 6 | dockerfile: tests.dockerfile 7 | command: bash -c "while true; do sleep 1; done" 8 | env_file: 9 | - env-couchbase.env 10 | - env-sync-gateway.env 11 | - env-backend.env 12 | environment: 13 | - SERVER_NAME=backend 14 | backend: 15 | environment: 16 | # Don't send emails during testing 17 | - SMTP_HOST= 18 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/env-backend.env: -------------------------------------------------------------------------------- 1 | BACKEND_CORS_ORIGINS={{cookiecutter.backend_cors_origins}} 2 | PROJECT_NAME={{cookiecutter.project_name}} 3 | SECRET_KEY={{cookiecutter.secret_key}} 4 | FIRST_SUPERUSER={{cookiecutter.first_superuser}} 5 | FIRST_SUPERUSER_PASSWORD={{cookiecutter.first_superuser_password}} 6 | SMTP_TLS=True 7 | SMTP_PORT={{cookiecutter.smtp_port}} 8 | SMTP_HOST={{cookiecutter.smtp_host}} 9 | SMTP_USER={{cookiecutter.smtp_user}} 10 | SMTP_PASSWORD={{cookiecutter.smtp_password}} 11 | EMAILS_FROM_EMAIL={{cookiecutter.smtp_emails_from_email}} 12 | 13 | USERS_OPEN_REGISTRATION=False 14 | 15 | SENTRY_DSN={{cookiecutter.sentry_dsn}} 16 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/env-couchbase.env: -------------------------------------------------------------------------------- 1 | COUCHBASE_HOST=couchbase 2 | COUCHBASE_PORT=8091 3 | COUCHBASE_FULL_TEXT_PORT=8094 4 | COUCHBASE_ENTERPRISE=False 5 | COUCHBASE_MEMORY_QUOTA_MB=256 6 | COUCHBASE_INDEX_MEMORY_QUOTA_MB=256 7 | COUCHBASE_FTS_MEMORY_QUOTA_MB=256 8 | COUCHBASE_BUCKET_NAME=app 9 | COUCHBASE_USER={{cookiecutter.couchbase_user}} 10 | COUCHBASE_PASSWORD={{cookiecutter.couchbase_password}} 11 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/env-flower.env: -------------------------------------------------------------------------------- 1 | FLOWER_BASIC_AUTH={{cookiecutter.flower_auth}} 2 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/env-sync-gateway.env: -------------------------------------------------------------------------------- 1 | COUCHBASE_SYNC_GATEWAY_HOST=sync-gateway 2 | COUCHBASE_SYNC_GATEWAY_PORT=4985 3 | COUCHBASE_SYNC_GATEWAY_USER={{cookiecutter.couchbase_sync_gateway_user}} 4 | COUCHBASE_SYNC_GATEWAY_PASSWORD={{cookiecutter.couchbase_sync_gateway_password}} 5 | COUCHBASE_SYNC_GATEWAY_CORS_ORIGINS={{cookiecutter.couchbase_sync_gateway_cors}} 6 | COUCHBASE_SYNC_GATEWAY_DATABASE=db 7 | COUCHBASE_SYNC_GATEWAY_DISABLE_GUEST_USER=true 8 | COUCHBASE_SYNC_GATEWAY_NUM_INDEX_REPLICAS=0 9 | COUCHBASE_SYNC_GATEWAY_LOG=* 10 | COUCHBASE_SYNC_GATEWAY_ADMIN_INTERFACE=:4985 -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/.env: -------------------------------------------------------------------------------- 1 | VUE_APP_DOMAIN_DEV=localhost 2 | # VUE_APP_DOMAIN_DEV=local.dockertoolbox.tiangolo.com 3 | # VUE_APP_DOMAIN_DEV=localhost.tiangolo.com 4 | # VUE_APP_DOMAIN_DEV=dev.{{cookiecutter.domain_main}} 5 | VUE_APP_DOMAIN_STAG={{cookiecutter.domain_staging}} 6 | VUE_APP_DOMAIN_PROD={{cookiecutter.domain_main}} 7 | VUE_APP_NAME={{cookiecutter.project_name}} 8 | VUE_APP_ENV=development 9 | # VUE_APP_ENV=staging 10 | # VUE_APP_ENV=production 11 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw* 22 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 0, "build-stage", based on Node.js, to build and compile the frontend 2 | FROM tiangolo/node-frontend:10 as build-stage 3 | 4 | WORKDIR /app 5 | 6 | COPY package*.json /app/ 7 | 8 | RUN npm install 9 | 10 | COPY ./ /app/ 11 | 12 | ARG FRONTEND_ENV=production 13 | 14 | ENV VUE_APP_ENV=${FRONTEND_ENV} 15 | 16 | # Comment out the next line to disable tests 17 | RUN npm run test:unit 18 | 19 | RUN npm run build 20 | 21 | 22 | # Stage 1, based on Nginx, to have only the compiled app, ready for production with Nginx 23 | FROM nginx:1.15 24 | 25 | COPY --from=build-stage /app/dist/ /usr/share/nginx/html 26 | 27 | COPY --from=build-stage /nginx.conf /etc/nginx/conf.d/default.conf 28 | COPY ./nginx-backend-not-found.conf /etc/nginx/extra-conf.d/backend-not-found.conf 29 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/README.md: -------------------------------------------------------------------------------- 1 | # frontend 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Run your tests 19 | ``` 20 | npm run test 21 | ``` 22 | 23 | ### Lints and fixes files 24 | ``` 25 | npm run lint 26 | ``` 27 | 28 | ### Run your unit tests 29 | ``` 30 | npm run test:unit 31 | ``` 32 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "presets": [ 3 | [ 4 | "@vue/app", 5 | { 6 | "useBuiltIns": "entry" 7 | } 8 | ] 9 | ] 10 | } -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/nginx-backend-not-found.conf: -------------------------------------------------------------------------------- 1 | location /api { 2 | return 404; 3 | } 4 | location /docs { 5 | return 404; 6 | } 7 | location /redoc { 8 | return 404; 9 | } 10 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint", 9 | "test:unit": "vue-cli-service test:unit" 10 | }, 11 | "dependencies": { 12 | "@babel/polyfill": "^7.2.5", 13 | "axios": "^0.18.0", 14 | "register-service-worker": "^1.0.0", 15 | "typesafe-vuex": "^3.1.1", 16 | "vee-validate": "^2.1.7", 17 | "vue": "^2.5.22", 18 | "vue-class-component": "^6.0.0", 19 | "vue-property-decorator": "^7.3.0", 20 | "vue-router": "^3.0.2", 21 | "vuetify": "^1.4.4", 22 | "vuex": "^3.1.0" 23 | }, 24 | "devDependencies": { 25 | "@types/jest": "^23.3.13", 26 | "@vue/cli-plugin-babel": "^3.3.0", 27 | "@vue/cli-plugin-pwa": "^3.3.0", 28 | "@vue/cli-plugin-typescript": "^3.3.0", 29 | "@vue/cli-plugin-unit-jest": "^3.5.0", 30 | "@vue/cli-service": "^3.3.1", 31 | "@vue/test-utils": "^1.0.0-beta.28", 32 | "babel-core": "7.0.0-bridge.0", 33 | "ts-jest": "^23.10.5", 34 | "typescript": "^3.2.4", 35 | "vue-cli-plugin-vuetify": "^0.2.1", 36 | "vue-template-compiler": "^2.5.22" 37 | }, 38 | "postcss": { 39 | "plugins": { 40 | "autoprefixer": {} 41 | } 42 | }, 43 | "browserslist": [ 44 | "> 1%", 45 | "last 2 versions", 46 | "not ie <= 10" 47 | ], 48 | "jest": { 49 | "moduleFileExtensions": [ 50 | "js", 51 | "jsx", 52 | "json", 53 | "vue", 54 | "ts", 55 | "tsx" 56 | ], 57 | "transform": { 58 | "^.+\\.vue$": "vue-jest", 59 | ".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$": "jest-transform-stub", 60 | "^.+\\.tsx?$": "ts-jest" 61 | }, 62 | "moduleNameMapper": { 63 | "^@/(.*)$": "/src/$1" 64 | }, 65 | "snapshotSerializers": [ 66 | "jest-serializer-vue" 67 | ], 68 | "testMatch": [ 69 | "**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)" 70 | ], 71 | "testURL": "http://localhost/" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/frontend/public/favicon.ico -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/frontend/public/img/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/img/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/frontend/public/img/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/frontend/public/img/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/frontend/public/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/frontend/public/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/img/icons/msapplication-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/frontend/public/img/icons/msapplication-icon-144x144.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/img/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/frontend/public/img/icons/mstile-150x150.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= VUE_APP_NAME %> 9 | 10 | 11 | 12 | 13 | 14 | 15 | 18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "short_name": "frontend", 4 | "icons": [ 5 | { 6 | "src": "/img/icons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/img/icons/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "start_url": "/", 17 | "display": "standalone", 18 | "background_color": "#000000", 19 | "theme_color": "#4DBA87" 20 | } 21 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 44 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { apiUrl } from '@/env'; 3 | import { IUserProfile, IUserProfileUpdate, IUserProfileCreate } from './interfaces'; 4 | 5 | function authHeaders(token: string) { 6 | return { 7 | headers: { 8 | Authorization: `Bearer ${token}`, 9 | }, 10 | }; 11 | } 12 | 13 | export const api = { 14 | async logInGetToken(username: string, password: string) { 15 | const params = new URLSearchParams(); 16 | params.append('username', username); 17 | params.append('password', password); 18 | 19 | return axios.post(`${apiUrl}/api/v1/login/access-token`, params); 20 | }, 21 | async getMe(token: string) { 22 | return axios.get(`${apiUrl}/api/v1/users/me`, authHeaders(token)); 23 | }, 24 | async updateMe(token: string, data: IUserProfileUpdate) { 25 | return axios.put(`${apiUrl}/api/v1/users/me`, data, authHeaders(token)); 26 | }, 27 | async getUsers(token: string) { 28 | return axios.get(`${apiUrl}/api/v1/users/`, authHeaders(token)); 29 | }, 30 | async updateUser(token: string, username: string, data: IUserProfileUpdate) { 31 | return axios.put(`${apiUrl}/api/v1/users/${username}`, data, authHeaders(token)); 32 | }, 33 | async createUser(token: string, data: IUserProfileCreate) { 34 | return axios.post(`${apiUrl}/api/v1/users/`, data, authHeaders(token)); 35 | }, 36 | async getRoles(token: string) { 37 | return axios.get(`${apiUrl}/api/v1/roles/`, authHeaders(token)); 38 | }, 39 | async passwordRecovery(username: string) { 40 | return axios.post(`${apiUrl}/api/v1/password-recovery/${username}`); 41 | }, 42 | async resetPassword(password: string, token: string) { 43 | return axios.post(`${apiUrl}/api/v1/reset-password/`, { 44 | new_password: password, 45 | token, 46 | }); 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/full-stack-fastapi-couchbase/28861b99b35769b816e1ee94b070270ca2fe2301/{{cookiecutter.project_slug}}/frontend/src/assets/logo.png -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/component-hooks.ts: -------------------------------------------------------------------------------- 1 | import Component from 'vue-class-component'; 2 | 3 | // Register the router hooks with their names 4 | Component.registerHooks([ 5 | 'beforeRouteEnter', 6 | 'beforeRouteLeave', 7 | 'beforeRouteUpdate', // for vue-router 2.2+ 8 | ]); 9 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/components/NotificationsManager.vue: -------------------------------------------------------------------------------- 1 | 9 | 78 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/components/RouterComponent.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/components/UploadButton.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 25 | 26 | 35 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/env.ts: -------------------------------------------------------------------------------- 1 | const env = process.env.VUE_APP_ENV; 2 | 3 | let envApiUrl = ''; 4 | 5 | if (env === 'production') { 6 | envApiUrl = `https://${process.env.VUE_APP_DOMAIN_PROD}`; 7 | } else if (env === 'staging') { 8 | envApiUrl = `https://${process.env.VUE_APP_DOMAIN_STAG}`; 9 | } else { 10 | envApiUrl = `http://${process.env.VUE_APP_DOMAIN_DEV}`; 11 | } 12 | 13 | export const apiUrl = envApiUrl; 14 | export const appName = process.env.VUE_APP_NAME; 15 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export interface IUserProfile { 2 | admin_channels: string[]; 3 | admin_roles: string[]; 4 | disabled: boolean; 5 | email: string; 6 | full_name: string; 7 | username: string; 8 | } 9 | 10 | export interface IUserProfileUpdate { 11 | full_name?: string; 12 | password?: string; 13 | email?: string; 14 | admin_channels?: string[]; 15 | admin_roles?: string[]; 16 | disabled?: boolean; 17 | } 18 | 19 | export interface IUserProfileCreate { 20 | username: string; 21 | full_name?: string; 22 | password?: string; 23 | email?: string; 24 | admin_channels?: string[]; 25 | admin_roles?: string[]; 26 | disabled?: boolean; 27 | } 28 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | import '@babel/polyfill'; 2 | // Import Component hooks before component definitions 3 | import './component-hooks'; 4 | import Vue from 'vue'; 5 | import './plugins/vuetify'; 6 | import './plugins/vee-validate'; 7 | import App from './App.vue'; 8 | import router from './router'; 9 | import store from '@/store'; 10 | import './registerServiceWorker'; 11 | import 'vuetify/dist/vuetify.min.css'; 12 | 13 | Vue.config.productionTip = false; 14 | 15 | new Vue({ 16 | router, 17 | store, 18 | render: (h) => h(App), 19 | }).$mount('#app'); 20 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/plugins/vee-validate.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VeeValidate from 'vee-validate'; 3 | 4 | Vue.use(VeeValidate); 5 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuetify from 'vuetify'; 3 | 4 | Vue.use(Vuetify, { 5 | iconfont: 'md', 6 | }); 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/registerServiceWorker.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-console */ 2 | 3 | import { register } from 'register-service-worker'; 4 | 5 | if (process.env.NODE_ENV === 'production') { 6 | register(`${process.env.BASE_URL}service-worker.js`, { 7 | ready() { 8 | console.log( 9 | 'App is being served from cache by a service worker.\n' + 10 | 'For more details, visit https://goo.gl/AFskqB', 11 | ); 12 | }, 13 | cached() { 14 | console.log('Content has been cached for offline use.'); 15 | }, 16 | updated() { 17 | console.log('New content is available; please refresh.'); 18 | }, 19 | offline() { 20 | console.log('No internet connection found. App is running in offline mode.'); 21 | }, 22 | error(error) { 23 | console.error('Error during service worker registration:', error); 24 | }, 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/router.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Router from 'vue-router'; 3 | 4 | import RouterComponent from './components/RouterComponent.vue'; 5 | 6 | Vue.use(Router); 7 | 8 | export default new Router({ 9 | mode: 'history', 10 | base: process.env.BASE_URL, 11 | routes: [ 12 | { 13 | path: '/', 14 | component: () => import(/* webpackChunkName: "start" */ './views/main/Start.vue'), 15 | children: [ 16 | { 17 | path: 'login', 18 | // route level code-splitting 19 | // this generates a separate chunk (about.[hash].js) for this route 20 | // which is lazy-loaded when the route is visited. 21 | component: () => import(/* webpackChunkName: "login" */ './views/Login.vue'), 22 | }, 23 | { 24 | path: 'recover-password', 25 | component: () => import(/* webpackChunkName: "recover-password" */ './views/PasswordRecovery.vue'), 26 | }, 27 | { 28 | path: 'reset-password', 29 | component: () => import(/* webpackChunkName: "reset-password" */ './views/ResetPassword.vue'), 30 | }, 31 | { 32 | path: 'main', 33 | component: () => import(/* webpackChunkName: "main" */ './views/main/Main.vue'), 34 | children: [ 35 | { 36 | path: 'dashboard', 37 | component: () => import(/* webpackChunkName: "main-dashboard" */ './views/main/Dashboard.vue'), 38 | }, 39 | { 40 | path: 'profile', 41 | component: RouterComponent, 42 | redirect: 'profile/view', 43 | children: [ 44 | { 45 | path: 'view', 46 | component: () => import( 47 | /* webpackChunkName: "main-profile" */ './views/main/profile/UserProfile.vue'), 48 | }, 49 | { 50 | path: 'edit', 51 | component: () => import( 52 | /* webpackChunkName: "main-profile-edit" */ './views/main/profile/UserProfileEdit.vue'), 53 | }, 54 | { 55 | path: 'password', 56 | component: () => import( 57 | /* webpackChunkName: "main-profile-password" */ './views/main/profile/UserProfileEditPassword.vue'), 58 | }, 59 | ], 60 | }, 61 | { 62 | path: 'admin', 63 | component: () => import(/* webpackChunkName: "main-admin" */ './views/main/admin/Admin.vue'), 64 | redirect: 'admin/users/all', 65 | children: [ 66 | { 67 | path: 'users', 68 | redirect: 'users/all', 69 | }, 70 | { 71 | path: 'users/all', 72 | component: () => import( 73 | /* webpackChunkName: "main-admin-users" */ './views/main/admin/AdminUsers.vue'), 74 | }, 75 | { 76 | path: 'users/edit/:username', 77 | name: 'main-admin-users-edit', 78 | component: () => import( 79 | /* webpackChunkName: "main-admin-users-edit" */ './views/main/admin/EditUser.vue'), 80 | }, 81 | { 82 | path: 'users/create', 83 | name: 'main-admin-users-create', 84 | component: () => import( 85 | /* webpackChunkName: "main-admin-users-create" */ './views/main/admin/CreateUser.vue'), 86 | }, 87 | ], 88 | }, 89 | ], 90 | }, 91 | ], 92 | }, 93 | { 94 | path: '/*', redirect: '/', 95 | }, 96 | ], 97 | }); 98 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue'; 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue'; 3 | export default Vue; 4 | } 5 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/store/admin/actions.ts: -------------------------------------------------------------------------------- 1 | import { api } from '@/api'; 2 | import { ActionContext } from 'vuex'; 3 | import { IUserProfileCreate, IUserProfileUpdate } from '@/interfaces'; 4 | import { State } from '../state'; 5 | import { AdminState } from './state'; 6 | import { getStoreAccessors } from 'typesafe-vuex'; 7 | import { commitSetUsers, commitSetUser, commitSetRoles } from './mutations'; 8 | import { dispatchCheckApiError } from '../main/actions'; 9 | import { commitAddNotification, commitRemoveNotification } from '../main/mutations'; 10 | 11 | type MainContext = ActionContext; 12 | 13 | export const actions = { 14 | async actionGetUsers(context: MainContext) { 15 | try { 16 | const response = await api.getUsers(context.rootState.main.token); 17 | if (response) { 18 | commitSetUsers(context, response.data); 19 | } 20 | } catch (error) { 21 | await dispatchCheckApiError(context, error); 22 | } 23 | }, 24 | async actionUpdateUser(context: MainContext, payload: { username: string, user: IUserProfileUpdate }) { 25 | try { 26 | const loadingNotification = { content: 'saving', showProgress: true }; 27 | commitAddNotification(context, loadingNotification); 28 | const response = (await Promise.all([ 29 | api.updateUser(context.rootState.main.token, payload.username, payload.user), 30 | await new Promise((resolve, reject) => setTimeout(() => resolve(), 500)), 31 | ]))[0]; 32 | commitSetUser(context, response.data); 33 | commitRemoveNotification(context, loadingNotification); 34 | commitAddNotification(context, {content: 'User successfully updated', color: 'success'}); 35 | } catch (error) { 36 | await dispatchCheckApiError(context, error); 37 | } 38 | }, 39 | async actionCreateUser(context: MainContext, payload: IUserProfileCreate) { 40 | try { 41 | const loadingNotification = { content: 'saving', showProgress: true }; 42 | commitAddNotification(context, loadingNotification); 43 | const response = (await Promise.all([ 44 | api.createUser(context.rootState.main.token, payload), 45 | await new Promise((resolve, reject) => setTimeout(() => resolve(), 500)), 46 | ]))[0]; 47 | commitSetUser(context, response.data); 48 | commitRemoveNotification(context, loadingNotification); 49 | commitAddNotification(context, { content: 'User successfully created', color: 'success' }); 50 | } catch (error) { 51 | await dispatchCheckApiError(context, error); 52 | } 53 | }, 54 | async actionGetRoles(context: MainContext) { 55 | try { 56 | const response = await api.getRoles(context.rootState.main.token); 57 | commitSetRoles(context, response.data.roles); 58 | } catch (error) { 59 | await dispatchCheckApiError(context, error); 60 | } 61 | }, 62 | }; 63 | 64 | const { dispatch } = getStoreAccessors(''); 65 | 66 | export const dispatchCreateUser = dispatch(actions.actionCreateUser); 67 | export const dispatchGetRoles = dispatch(actions.actionGetRoles); 68 | export const dispatchGetUsers = dispatch(actions.actionGetUsers); 69 | export const dispatchUpdateUser = dispatch(actions.actionUpdateUser); 70 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/store/admin/getters.ts: -------------------------------------------------------------------------------- 1 | import { AdminState } from './state'; 2 | import { getStoreAccessors } from 'typesafe-vuex'; 3 | import { State } from '../state'; 4 | 5 | export const getters = { 6 | adminUsers: (state: AdminState) => state.users, 7 | adminRoles: (state: AdminState) => state.roles, 8 | adminOneUser: (state: AdminState) => (username: string) => { 9 | const filteredUsers = state.users.filter((user) => user.username === username); 10 | if (filteredUsers.length > 0) { 11 | return { ...filteredUsers[0] }; 12 | } 13 | }, 14 | }; 15 | 16 | const { read } = getStoreAccessors(''); 17 | 18 | export const readAdminOneUser = read(getters.adminOneUser); 19 | export const readAdminRoles = read(getters.adminRoles); 20 | export const readAdminUsers = read(getters.adminUsers); 21 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/store/admin/index.ts: -------------------------------------------------------------------------------- 1 | import { mutations } from './mutations'; 2 | import { getters } from './getters'; 3 | import { actions } from './actions'; 4 | import { AdminState } from './state'; 5 | 6 | const defaultState: AdminState = { 7 | users: [], 8 | roles: [], 9 | }; 10 | 11 | export const adminModule = { 12 | state: defaultState, 13 | mutations, 14 | actions, 15 | getters, 16 | }; 17 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/store/admin/mutations.ts: -------------------------------------------------------------------------------- 1 | import { IUserProfile } from '@/interfaces'; 2 | import { AdminState } from './state'; 3 | import { getStoreAccessors } from 'typesafe-vuex'; 4 | import { State } from '../state'; 5 | 6 | export const mutations = { 7 | setUsers(state: AdminState, payload: IUserProfile[]) { 8 | state.users = payload; 9 | }, 10 | setUser(state: AdminState, payload: IUserProfile) { 11 | const users = state.users.filter((user: IUserProfile) => user.username !== payload.username); 12 | users.push(payload); 13 | state.users = users; 14 | }, 15 | setRoles(state: AdminState, payload: string[]) { 16 | state.roles = payload; 17 | }, 18 | }; 19 | 20 | const { commit } = getStoreAccessors(''); 21 | 22 | export const commitSetUser = commit(mutations.setUser); 23 | export const commitSetUsers = commit(mutations.setUsers); 24 | export const commitSetRoles = commit(mutations.setRoles); 25 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/store/admin/state.ts: -------------------------------------------------------------------------------- 1 | import { IUserProfile } from '@/interfaces'; 2 | 3 | export interface AdminState { 4 | users: IUserProfile[]; 5 | roles: string[]; 6 | } 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex, { StoreOptions } from 'vuex'; 3 | 4 | import { mainModule } from './main'; 5 | import { State } from './state'; 6 | import { adminModule } from './admin'; 7 | 8 | Vue.use(Vuex); 9 | 10 | const storeOptions: StoreOptions = { 11 | modules: { 12 | main: mainModule, 13 | admin: adminModule, 14 | }, 15 | }; 16 | 17 | export const store = new Vuex.Store(storeOptions); 18 | 19 | export default store; 20 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/store/main/getters.ts: -------------------------------------------------------------------------------- 1 | import { MainState } from './state'; 2 | import { getStoreAccessors } from 'typesafe-vuex'; 3 | import { State } from '../state'; 4 | 5 | export const getters = { 6 | hasAdminAccess: (state: MainState) => { 7 | return ( 8 | state.userProfile && 9 | state.userProfile.admin_roles.includes('superuser')); 10 | }, 11 | loginError: (state: MainState) => state.logInError, 12 | dashboardShowDrawer: (state: MainState) => state.dashboardShowDrawer, 13 | dashboardMiniDrawer: (state: MainState) => state.dashboardMiniDrawer, 14 | userProfile: (state: MainState) => state.userProfile, 15 | token: (state: MainState) => state.token, 16 | isLoggedIn: (state: MainState) => state.isLoggedIn, 17 | firstNotification: (state: MainState) => state.notifications.length > 0 && state.notifications[0], 18 | }; 19 | 20 | const {read} = getStoreAccessors(''); 21 | 22 | export const readDashboardMiniDrawer = read(getters.dashboardMiniDrawer); 23 | export const readDashboardShowDrawer = read(getters.dashboardShowDrawer); 24 | export const readHasAdminAccess = read(getters.hasAdminAccess); 25 | export const readIsLoggedIn = read(getters.isLoggedIn); 26 | export const readLoginError = read(getters.loginError); 27 | export const readToken = read(getters.token); 28 | export const readUserProfile = read(getters.userProfile); 29 | export const readFirstNotification = read(getters.firstNotification); 30 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/store/main/index.ts: -------------------------------------------------------------------------------- 1 | import { mutations } from './mutations'; 2 | import { getters } from './getters'; 3 | import { actions } from './actions'; 4 | import { MainState } from './state'; 5 | 6 | const defaultState: MainState = { 7 | isLoggedIn: null, 8 | token: '', 9 | logInError: false, 10 | userProfile: null, 11 | dashboardMiniDrawer: false, 12 | dashboardShowDrawer: true, 13 | notifications: [], 14 | }; 15 | 16 | export const mainModule = { 17 | state: defaultState, 18 | mutations, 19 | actions, 20 | getters, 21 | }; 22 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/store/main/mutations.ts: -------------------------------------------------------------------------------- 1 | import { IUserProfile } from '@/interfaces'; 2 | import { MainState, AppNotification } from './state'; 3 | import { getStoreAccessors } from 'typesafe-vuex'; 4 | import { State } from '../state'; 5 | 6 | 7 | export const mutations = { 8 | setToken(state: MainState, payload: string) { 9 | state.token = payload; 10 | }, 11 | setLoggedIn(state: MainState, payload: boolean) { 12 | state.isLoggedIn = payload; 13 | }, 14 | setLogInError(state: MainState, payload: boolean) { 15 | state.logInError = payload; 16 | }, 17 | setUserProfile(state: MainState, payload: IUserProfile) { 18 | state.userProfile = payload; 19 | }, 20 | setDashboardMiniDrawer(state: MainState, payload: boolean) { 21 | state.dashboardMiniDrawer = payload; 22 | }, 23 | setDashboardShowDrawer(state: MainState, payload: boolean) { 24 | state.dashboardShowDrawer = payload; 25 | }, 26 | addNotification(state: MainState, payload: AppNotification) { 27 | state.notifications.push(payload); 28 | }, 29 | removeNotification(state: MainState, payload: AppNotification) { 30 | state.notifications = state.notifications.filter((notification) => notification !== payload); 31 | }, 32 | }; 33 | 34 | const {commit} = getStoreAccessors(''); 35 | 36 | export const commitSetDashboardMiniDrawer = commit(mutations.setDashboardMiniDrawer); 37 | export const commitSetDashboardShowDrawer = commit(mutations.setDashboardShowDrawer); 38 | export const commitSetLoggedIn = commit(mutations.setLoggedIn); 39 | export const commitSetLogInError = commit(mutations.setLogInError); 40 | export const commitSetToken = commit(mutations.setToken); 41 | export const commitSetUserProfile = commit(mutations.setUserProfile); 42 | export const commitAddNotification = commit(mutations.addNotification); 43 | export const commitRemoveNotification = commit(mutations.removeNotification); 44 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/store/main/state.ts: -------------------------------------------------------------------------------- 1 | import { IUserProfile } from '@/interfaces'; 2 | 3 | export interface AppNotification { 4 | content: string; 5 | color?: string; 6 | showProgress?: boolean; 7 | } 8 | 9 | export interface MainState { 10 | token: string; 11 | isLoggedIn: boolean | null; 12 | logInError: boolean; 13 | userProfile: IUserProfile | null; 14 | dashboardMiniDrawer: boolean; 15 | dashboardShowDrawer: boolean; 16 | notifications: AppNotification[]; 17 | } 18 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/store/state.ts: -------------------------------------------------------------------------------- 1 | import { MainState } from './main/state'; 2 | 3 | export interface State { 4 | main: MainState; 5 | } 6 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/utils.ts: -------------------------------------------------------------------------------- 1 | export const getLocalToken = () => localStorage.getItem('token'); 2 | 3 | export const saveLocalToken = (token: string) => localStorage.setItem('token', token); 4 | 5 | export const removeLocalToken = () => localStorage.removeItem('token'); 6 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/Login.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 56 | 57 | 59 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/PasswordRecovery.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 50 | 51 | 53 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/ResetPassword.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 85 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/main/Dashboard.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 38 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/main/Start.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 39 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/main/admin/Admin.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 34 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/main/admin/AdminUsers.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 85 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/main/admin/CreateUser.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 115 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/main/profile/UserProfile.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 52 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/main/profile/UserProfileEdit.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 103 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/src/views/main/profile/UserProfileEditPassword.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 87 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/tests/unit/upload-button.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '@vue/test-utils'; 2 | import UploadButton from '@/components/UploadButton.vue'; 3 | import '@/plugins/vuetify'; 4 | 5 | describe('UploadButton.vue', () => { 6 | it('renders props.title when passed', () => { 7 | const title = 'upload a file'; 8 | const wrapper = shallowMount(UploadButton, { 9 | slots: { 10 | default: title, 11 | }, 12 | }); 13 | expect(wrapper.text()).toMatch(title); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": false, 4 | "target": "esnext", 5 | "module": "esnext", 6 | "strict": true, 7 | "jsx": "preserve", 8 | "importHelpers": true, 9 | "moduleResolution": "node", 10 | "experimentalDecorators": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "sourceMap": true, 14 | "baseUrl": ".", 15 | "types": [ 16 | "webpack-env", 17 | "jest" 18 | ], 19 | "paths": { 20 | "@/*": [ 21 | "src/*" 22 | ] 23 | }, 24 | "lib": [ 25 | "esnext", 26 | "dom", 27 | "dom.iterable", 28 | "scripthost" 29 | ] 30 | }, 31 | "include": [ 32 | "src/**/*.ts", 33 | "src/**/*.tsx", 34 | "src/**/*.vue", 35 | "tests/**/*.ts", 36 | "tests/**/*.tsx" 37 | ], 38 | "exclude": [ 39 | "node_modules" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "warning", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "linterOptions": { 7 | "exclude": [ 8 | "node_modules/**" 9 | ] 10 | }, 11 | "rules": { 12 | "quotemark": [true, "single"], 13 | "indent": [true, "spaces", 2], 14 | "interface-name": false, 15 | "ordered-imports": false, 16 | "object-literal-sort-keys": false, 17 | "no-consecutive-blank-lines": false 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/frontend/vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Fix Vuex-typescript in prod: https://github.com/istrib/vuex-typescript/issues/13#issuecomment-409869231 3 | configureWebpack: (config) => { 4 | if (process.env.NODE_ENV === 'production') { 5 | config.optimization.minimizer[0].options.terserOptions = Object.assign( 6 | {}, 7 | config.optimization.minimizer[0].options.terserOptions, 8 | { 9 | ecma: 5, 10 | compress: { 11 | keep_fnames: true, 12 | }, 13 | warnings: false, 14 | mangle: { 15 | keep_fnames: true, 16 | }, 17 | }, 18 | ); 19 | } 20 | }, 21 | chainWebpack: config => { 22 | config.module 23 | .rule('vue') 24 | .use('vue-loader') 25 | .loader('vue-loader') 26 | .tap(options => Object.assign(options, { 27 | transformAssetUrls: { 28 | 'v-img': ['src', 'lazy-src'], 29 | 'v-card': 'src', 30 | 'v-card-media': 'src', 31 | 'v-responsive': 'src', 32 | } 33 | })); 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/scripts/build-push.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | TAG=${TAG} \ 7 | FRONTEND_ENV=${FRONTEND_ENV-production} \ 8 | source ./scripts/build.sh 9 | 10 | docker-compose -f docker-stack.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} \ 7 | FRONTEND_ENV=${FRONTEND_ENV-production} \ 8 | docker-compose \ 9 | -f docker-compose.deploy.build.yml \ 10 | -f docker-compose.deploy.images.yml \ 11 | config > docker-stack.yml 12 | 13 | docker-compose -f docker-stack.yml build 14 | -------------------------------------------------------------------------------- /{{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} \ 7 | TRAEFIK_TAG=${TRAEFIK_TAG} \ 8 | STACK_NAME=${STACK_NAME} \ 9 | TAG=${TAG} \ 10 | docker-compose \ 11 | -f docker-compose.shared.admin.yml \ 12 | -f docker-compose.shared.base-images.yml \ 13 | -f docker-compose.shared.depends.yml \ 14 | -f docker-compose.shared.env.yml \ 15 | -f docker-compose.deploy.command.yml \ 16 | -f docker-compose.deploy.images.yml \ 17 | -f docker-compose.deploy.labels.yml \ 18 | -f docker-compose.deploy.networks.yml \ 19 | -f docker-compose.deploy.volumes-placement.yml \ 20 | config > docker-stack.yml 21 | 22 | docker-auto-labels docker-stack.yml 23 | 24 | docker stack deploy -c docker-stack.yml --with-registry-auth ${STACK_NAME} 25 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/scripts/test-local.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | if [ $(uname -s) = "Linux" ]; then 7 | echo "Remove __pycache__ files" 8 | sudo find . -type d -name __pycache__ -exec rm -r {} \+ 9 | fi 10 | 11 | docker-compose \ 12 | -f docker-compose.test.yml \ 13 | -f docker-compose.shared.admin.yml \ 14 | -f docker-compose.shared.base-images.yml \ 15 | -f docker-compose.shared.depends.yml \ 16 | -f docker-compose.shared.env.yml \ 17 | -f docker-compose.dev.build.yml \ 18 | -f docker-compose.dev.env.yml \ 19 | -f docker-compose.dev.labels.yml \ 20 | -f docker-compose.dev.networks.yml \ 21 | -f docker-compose.dev.ports.yml \ 22 | -f docker-compose.dev.volumes.yml \ 23 | config > docker-stack.yml 24 | 25 | # -f docker-compose.dev.command.yml \ 26 | 27 | docker-compose -f docker-stack.yml build 28 | docker-compose -f docker-stack.yml down -v --remove-orphans # Remove possibly previous broken stacks left hanging after an error 29 | docker-compose -f docker-stack.yml up -d 30 | docker-compose -f docker-stack.yml exec -T backend-tests /tests-start.sh "$@" 31 | -------------------------------------------------------------------------------- /{{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 | docker-compose \ 8 | -f docker-compose.shared.base-images.yml \ 9 | -f docker-compose.shared.env.yml \ 10 | -f docker-compose.shared.depends.yml \ 11 | -f docker-compose.deploy.build.yml \ 12 | -f docker-compose.test.yml \ 13 | config > docker-stack.yml 14 | 15 | docker-compose -f docker-stack.yml build 16 | docker-compose -f docker-stack.yml down -v --remove-orphans # Remove possibly previous broken stacks left hanging after an error 17 | docker-compose -f docker-stack.yml up -d 18 | docker-compose -f docker-stack.yml exec -T backend-tests /tests-start.sh "$@" 19 | docker-compose -f docker-stack.yml down -v --remove-orphans 20 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/sync-gateway/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM couchbase/sync-gateway:2.1.0-community 2 | 3 | COPY /entrypoint.sh / 4 | RUN chmod +x ./entrypoint.sh 5 | 6 | COPY /create_config.py / 7 | COPY /sync/ /sync/ 8 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/sync-gateway/create_config.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | import os 3 | import json 4 | import logging 5 | 6 | logging.basicConfig(level=logging.INFO) 7 | 8 | 9 | def getenv_boolean(var_name, default_value=False): 10 | result = default_value 11 | env_value = os.getenv(var_name) 12 | if env_value is not None: 13 | result = env_value.upper() in ("TRUE", "1") 14 | return result 15 | 16 | 17 | # Env vars for configs 18 | COUCHBASE_HOST = os.getenv( 19 | "COUCHBASE_HOST", "" 20 | ) # e.g.: "couchbase". Leave empty to use default Walrus 21 | COUCHBASE_PORT = os.getenv("COUCHBASE_PORT", "8091") 22 | COUCHBASE_BUCKET_NAME = os.getenv("COUCHBASE_BUCKET_NAME", "app") 23 | COUCHBASE_SYNC_GATEWAY_LOG = os.getenv( 24 | "COUCHBASE_SYNC_GATEWAY_LOG", "HTTP+" 25 | ) # e.g.: "*" 26 | COUCHBASE_SYNC_GATEWAY_ADMIN_INTERFACE = os.getenv( 27 | "COUCHBASE_SYNC_GATEWAY_ADMIN_INTERFACE", "" 28 | ) # e.g.: ":4985", leave empty to disable 29 | COUCHBASE_SYNC_GATEWAY_USER = os.getenv("COUCHBASE_SYNC_GATEWAY_USER", "") 30 | COUCHBASE_SYNC_GATEWAY_PASSWORD = os.getenv("COUCHBASE_SYNC_GATEWAY_PASSWORD", "") 31 | COUCHBASE_SYNC_GATEWAY_DATABASE = os.getenv("COUCHBASE_SYNC_GATEWAY_DATABASE", "db") 32 | COUCHBASE_SYNC_GATEWAY_CORS_ORIGINS = os.getenv( 33 | "COUCHBASE_SYNC_GATEWAY_CORS_ORIGINS", "" 34 | ) 35 | COUCHBASE_SYNC_GATEWAY_NUM_INDEX_REPLICAS = int( 36 | os.getenv("COUCHBASE_SYNC_GATEWAY_NUM_INDEX_REPLICAS", "0") 37 | ) 38 | COUCHBASE_SYNC_GATEWAY_DISABLE_GUEST_USER = getenv_boolean( 39 | "COUCHBASE_SYNC_GATEWAY_DISABLE_GUEST_USER", default_value=False 40 | ) 41 | 42 | # Base config 43 | logging.info("Generating base config") 44 | config_dict = { 45 | "interface": ":4984", 46 | "logging": {"console": {"log_keys": [COUCHBASE_SYNC_GATEWAY_LOG]}}, 47 | "databases": {}, 48 | } 49 | 50 | # Admin interface 51 | logging.info("Checking adminInterface config config") 52 | if COUCHBASE_SYNC_GATEWAY_ADMIN_INTERFACE: 53 | logging.info("Generating adminInterface config config") 54 | config_dict["adminInterface"] = COUCHBASE_SYNC_GATEWAY_ADMIN_INTERFACE 55 | 56 | # CORS 57 | logging.info("Checking CORS config") 58 | if COUCHBASE_SYNC_GATEWAY_CORS_ORIGINS: 59 | cors_list = COUCHBASE_SYNC_GATEWAY_CORS_ORIGINS.split(",") 60 | use_cors = [origin.strip() for origin in cors_list] 61 | 62 | logging.info("Generating CORS config") 63 | config_dict["CORS"] = { 64 | "Origin": use_cors, 65 | "LoginOrigin": use_cors, 66 | "Headers": ["Content-Type"], 67 | "MaxAge": 17280000, 68 | } 69 | 70 | # Couchbase 71 | logging.info("Checking Couchbase config") 72 | if COUCHBASE_HOST and COUCHBASE_SYNC_GATEWAY_USER and COUCHBASE_SYNC_GATEWAY_PASSWORD: 73 | logging.info("Generating Couchbase config") 74 | use_server = "http://{COUCHBASE_HOST}:{COUCHBASE_PORT}".format( 75 | COUCHBASE_HOST=COUCHBASE_HOST, COUCHBASE_PORT=COUCHBASE_PORT 76 | ) 77 | 78 | config_dict["databases"][COUCHBASE_SYNC_GATEWAY_DATABASE] = { 79 | "server": use_server, 80 | "bucket": COUCHBASE_BUCKET_NAME, 81 | "username": COUCHBASE_SYNC_GATEWAY_USER, 82 | "password": COUCHBASE_SYNC_GATEWAY_PASSWORD, 83 | "num_index_replicas": COUCHBASE_SYNC_GATEWAY_NUM_INDEX_REPLICAS, 84 | "enable_shared_bucket_access": True, 85 | "import_docs": "continuous", 86 | "users": {"GUEST": {"disabled": COUCHBASE_SYNC_GATEWAY_DISABLE_GUEST_USER}}, 87 | } 88 | else: 89 | config_dict["databases"][COUCHBASE_SYNC_GATEWAY_DATABASE] = { 90 | "server": "walrus:/opt/couchbase-sync-gateway/data", 91 | "users": { 92 | "GUEST": { 93 | "disabled": COUCHBASE_SYNC_GATEWAY_DISABLE_GUEST_USER, 94 | "admin_channels": ["*"], 95 | } 96 | }, 97 | } 98 | 99 | # JS sync function 100 | logging.info("Checking JavaScript sync function file") 101 | js_function_path = "/sync/sync-function.js" 102 | sync_function = "" 103 | logging.info("Checking JS sync function from {}".format(js_function_path)) 104 | if os.path.isfile(js_function_path): 105 | logging.info("Reading JavaScript sync function file") 106 | with open(js_function_path) as f: 107 | sync_function = f.read() 108 | if sync_function: 109 | logging.info("Generating JavaScript sync function config") 110 | config_dict["databases"][COUCHBASE_SYNC_GATEWAY_DATABASE]["sync"] = sync_function 111 | 112 | # Generate JSON config 113 | logging.info("Writing final config JSON file") 114 | with open("/etc/sync_gateway/config.json", "w") as f: 115 | json.dump(config_dict, f, indent=2) 116 | logging.info("Done") 117 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/sync-gateway/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | set -e 4 | 5 | # Check connection in a separate function, that way, when 6 | # there's no connection available yet and the return of the command 7 | # is non-zero, the script doesn't exit yet 8 | check_connection() { 9 | curl -Is http://${COUCHBASE_SYNC_GATEWAY_USER}:${COUCHBASE_SYNC_GATEWAY_PASSWORD}@${COUCHBASE_HOST}:${COUCHBASE_PORT}/pools/default/buckets/${COUCHBASE_BUCKET_NAME} 2>&1 | grep "HTTP/1.1 200 OK" > /dev/null 10 | if [ $? -eq 0 ]; then 11 | echo "success"; 12 | else 13 | echo "error"; 14 | fi; 15 | } 16 | 17 | # Try to connect to Couchbase only if COUCHBASE_HOST was declared as an env var 18 | if [ ! -z $COUCHBASE_HOST ]; then 19 | # Try once per second, up to 300 times (5 min) 20 | SECONDS_TO_TRY=300 21 | for i in $(seq 1 $SECONDS_TO_TRY); do 22 | echo "Checking connection, trial ${i}"; 23 | result=$(check_connection); 24 | if [ $result == "success" ]; then 25 | echo "Success: connection checked"; 26 | sleep 1; 27 | break; 28 | else 29 | echo "Connection not available yet, sleeping 1 sec..."; 30 | sleep 1; 31 | echo "----------"; 32 | fi; 33 | done; 34 | fi; 35 | 36 | 37 | python create_config.py 38 | 39 | LOGFILE_DIR=/var/log/sync_gateway 40 | mkdir -p $LOGFILE_DIR 41 | 42 | LOGFILE_ACCESS=$LOGFILE_DIR/sync_gateway_access.log 43 | LOGFILE_ERROR=$LOGFILE_DIR/sync_gateway_error.log 44 | 45 | # Run SG and use tee to append stdout and stderr to separate logfiles 46 | # Process substitution described here: https://stackoverflow.com/a/692407 47 | exec sync_gateway "$@" > >(tee -a $LOGFILE_ACCESS) 2> >(tee -a $LOGFILE_ERROR >&2) 48 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/sync-gateway/sync/sync-function.js: -------------------------------------------------------------------------------- 1 | function (doc, oldDoc) { 2 | requireAdmin(); 3 | channel(doc.channels); 4 | } 5 | --------------------------------------------------------------------------------