├── .dockerignore ├── .env.docker.example ├── .env.local.example ├── .env.testing ├── .eslintrc.cjs ├── .github ├── CODEOWNERS ├── dependabot.yaml └── workflows │ ├── ci.yaml │ ├── docs.yaml │ └── pr-title.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .prettierignore ├── .prettierrc.json ├── .vscode ├── extensions.json └── settings.json ├── CONTRIBUTING.rst ├── LICENSE ├── Makefile ├── README.md ├── components.json ├── deploy ├── docker-compose.infra.yml └── docker │ ├── dev │ └── Dockerfile │ └── run │ ├── Dockerfile │ └── Dockerfile.distroless ├── docker-compose.override.yml ├── docker-compose.yml ├── docs ├── _static │ ├── badge.png │ └── badge.svg ├── api │ ├── asgi.rst │ ├── cli.rst │ ├── config.rst │ ├── db.rst │ ├── domain │ │ ├── accounts │ │ │ ├── controllers │ │ │ │ ├── access.rst │ │ │ │ ├── accounts.rst │ │ │ │ ├── index.rst │ │ │ │ └── users.rst │ │ │ ├── deps.rst │ │ │ ├── guards.rst │ │ │ ├── index.rst │ │ │ └── services.rst │ │ ├── index.rst │ │ ├── system │ │ │ ├── controllers.rst │ │ │ ├── index.rst │ │ │ └── tasks.rst │ │ ├── tags │ │ │ ├── controllers.rst │ │ │ ├── index.rst │ │ │ └── services.rst │ │ ├── teams │ │ │ ├── controllers │ │ │ │ ├── index.rst │ │ │ │ ├── team_invitation.rst │ │ │ │ ├── team_member.rst │ │ │ │ └── teams.rst │ │ │ ├── guards.rst │ │ │ ├── index.rst │ │ │ └── services.rst │ │ └── web │ │ │ ├── controllers.rst │ │ │ └── index.rst │ ├── index.rst │ ├── lib │ │ ├── crypt.rst │ │ ├── deps.rst │ │ ├── dto.rst │ │ ├── exceptions.rst │ │ ├── index.rst │ │ └── schema.rst │ └── server.rst ├── changelog.rst ├── conf.py ├── contribution-guide.rst ├── index.rst └── usage │ ├── development.rst │ ├── index.rst │ ├── installation.rst │ └── startup.rst ├── manage.py ├── nixpacks.toml ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── public ├── banner-dark.svg ├── banner-light.svg └── favicon.png ├── pyproject.toml ├── railway.json ├── resources ├── App.tsx ├── assets │ ├── .gitkeep │ └── favicon.png ├── components │ ├── icons.tsx │ ├── main-nav.tsx │ ├── mode-toggle.tsx │ ├── team-switcher.tsx │ ├── theme-provider.tsx │ ├── ui │ │ ├── avatar.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── checkbox.tsx │ │ ├── command.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── popover.tsx │ │ ├── select.tsx │ │ ├── sonner.tsx │ │ └── table.tsx │ └── user-nav.tsx ├── contexts │ └── AuthProvider.tsx ├── layouts │ ├── AuthLayout.tsx │ └── MainLayout.tsx ├── lib │ ├── protected-routes.tsx │ └── utils.ts ├── main.css ├── main.tsx ├── pages │ ├── Home.tsx │ ├── PageNotFound.tsx │ ├── Placeholder.tsx │ └── access │ │ ├── Login.tsx │ │ ├── Register.tsx │ │ └── components │ │ ├── user-login-form.tsx │ │ └── user-registration-form.tsx ├── services │ ├── auth.ts │ └── profile.ts ├── types │ ├── api.ts │ └── nav.ts └── vite-env.d.ts ├── sonar-project.properties ├── src └── app │ ├── __about__.py │ ├── __init__.py │ ├── __main__.py │ ├── asgi.py │ ├── cli │ ├── __init__.py │ └── commands.py │ ├── config │ ├── __init__.py │ ├── _utils.py │ ├── app.py │ ├── base.py │ └── constants.py │ ├── db │ ├── __init__.py │ ├── fixtures │ │ └── role.json │ ├── migrations │ │ ├── __init__.py │ │ ├── alembic.ini │ │ ├── env.py │ │ ├── script.py.mako │ │ └── versions │ │ │ ├── 2025-01-13_users_and_teams_1c703154d1d8.py │ │ │ └── __init__.py │ └── models │ │ ├── __init__.py │ │ ├── oauth_account.py │ │ ├── role.py │ │ ├── tag.py │ │ ├── team.py │ │ ├── team_invitation.py │ │ ├── team_member.py │ │ ├── team_roles.py │ │ ├── team_tag.py │ │ ├── user.py │ │ └── user_role.py │ ├── domain │ ├── __init__.py │ ├── accounts │ │ ├── __init__.py │ │ ├── controllers │ │ │ ├── __init__.py │ │ │ ├── access.py │ │ │ ├── roles.py │ │ │ ├── user_role.py │ │ │ └── users.py │ │ ├── deps.py │ │ ├── guards.py │ │ ├── schemas.py │ │ ├── services.py │ │ ├── signals.py │ │ └── urls.py │ ├── system │ │ ├── __init__.py │ │ ├── controllers.py │ │ ├── schemas.py │ │ ├── tasks.py │ │ └── urls.py │ ├── tags │ │ ├── __init__.py │ │ ├── controllers.py │ │ ├── services.py │ │ └── urls.py │ ├── teams │ │ ├── __init__.py │ │ ├── controllers │ │ │ ├── __init__.py │ │ │ ├── team_invitation.py │ │ │ ├── team_member.py │ │ │ └── teams.py │ │ ├── guards.py │ │ ├── schemas.py │ │ ├── services.py │ │ ├── signals.py │ │ └── urls.py │ └── web │ │ ├── __init__.py │ │ ├── controllers.py │ │ └── templates │ │ ├── email │ │ └── .gitkeep │ │ └── site │ │ ├── .gitkeep │ │ └── index.html.j2 │ ├── lib │ ├── __init__.py │ ├── crypt.py │ ├── deps.py │ ├── dto.py │ ├── exceptions.py │ ├── oauth.py │ └── schema.py │ ├── py.typed │ └── server │ ├── __init__.py │ ├── core.py │ └── plugins.py ├── tailwind.config.cjs ├── tests ├── __init__.py ├── conftest.py ├── data_fixtures.py ├── helpers.py ├── integration │ ├── __init__.py │ ├── conftest.py │ ├── test_access.py │ ├── test_account_role.py │ ├── test_accounts.py │ ├── test_health.py │ ├── test_tags.py │ ├── test_teams.py │ └── test_tests.py └── unit │ ├── __init__.py │ ├── conftest.py │ ├── lib │ ├── __init__.py │ ├── test_cache.py │ ├── test_crypt.py │ ├── test_exceptions.py │ ├── test_schema.py │ └── test_settings.py │ └── test_cli.py ├── tools ├── __init__.py ├── build_docs.py ├── manage_assets.py └── post_builds.py ├── tsconfig.json ├── uv.lock └── vite.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | .venv 2 | venv 3 | node_modules 4 | __pycache__ 5 | *.pyc 6 | dist/ 7 | tmp/ 8 | -------------------------------------------------------------------------------- /.env.docker.example: -------------------------------------------------------------------------------- 1 | # App 2 | SECRET_KEY='secret-key' 3 | LITESTAR_DEBUG=true 4 | LITESTAR_HOST=0.0.0.0 5 | LITESTAR_PORT=8000 6 | APP_URL=http://localhost:${LITESTAR_PORT} 7 | 8 | LOG_LEVEL=20 9 | # Database 10 | DATABASE_ECHO=false 11 | DATABASE_ECHO_POOL=false 12 | DATABASE_POOL_DISABLE=false 13 | DATABASE_POOL_MAX_OVERFLOW=5 14 | DATABASE_POOL_SIZE=5 15 | DATABASE_POOL_TIMEOUT=30 16 | DATABASE_URL=postgresql+asyncpg://app:app@db:5432/app 17 | 18 | # Cache 19 | REDIS_URL=redis://cache:6379/0 20 | 21 | SAQ_USE_SERVER_LIFESPAN=False # don't use with docker. 22 | SAQ_WEB_ENABLED=True 23 | SAQ_BACKGROUND_WORKERS=1 24 | SAQ_CONCURRENCY=1 25 | 26 | VITE_HOST=localhost 27 | VITE_PORT=3006 28 | ALLOWED_CORS_ORIGINS=["localhost:3006","localhost:8080","localhost:8000"] 29 | -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | # App 2 | SECRET_KEY='secret-key' 3 | LITESTAR_DEBUG=true 4 | LITESTAR_HOST=0.0.0.0 5 | LITESTAR_PORT=8089 6 | APP_URL=http://localhost:${LITESTAR_PORT} 7 | 8 | LOG_LEVEL=10 9 | # Database 10 | DATABASE_ECHO=true 11 | DATABASE_ECHO_POOL=true 12 | DATABASE_POOL_DISABLE=false 13 | DATABASE_POOL_MAX_OVERFLOW=5 14 | DATABASE_POOL_SIZE=5 15 | DATABASE_POOL_TIMEOUT=30 16 | DATABASE_URL=postgresql+asyncpg://app:app@localhost:15432/app 17 | 18 | REDIS_URL=redis://localhost:16379/0 19 | 20 | # Worker 21 | SAQ_USE_SERVER_LIFESPAN=True 22 | SAQ_WEB_ENABLED=True 23 | SAQ_BACKGROUND_WORKERS=1 24 | SAQ_CONCURRENCY=1 25 | 26 | VITE_HOST=localhost 27 | VITE_PORT=5174 28 | VITE_HOT_RELOAD=True 29 | VITE_DEV_MODE=True 30 | -------------------------------------------------------------------------------- /.env.testing: -------------------------------------------------------------------------------- 1 | # App 2 | SECRET_KEY='secret-key' 3 | 4 | DATABASE_ECHO=false 5 | DATABASE_ECHO_POOL=false 6 | # Cache 7 | VALKEY_PORT=6308 8 | REDIS_URL=redis://localhost:${VALKEY_PORT}/0 9 | 10 | SAQ_USE_SERVER_LIFESPAN=False # don't use with docker. 11 | SAQ_WEB_ENABLED=True 12 | SAQ_BACKGROUND_WORKERS=1 13 | SAQ_CONCURRENCY=1 14 | 15 | VITE_HOST=localhost 16 | VITE_PORT=3006 17 | VITE_HOT_RELOAD=True 18 | VITE_DEV_MODE=True 19 | VITE_USE_SERVER_LIFESPAN=False 20 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | "plugin:react/recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "prettier", 8 | "plugin:prettier/recommended", 9 | "plugin:import/recommended", 10 | "plugin:react-hooks/recommended", 11 | ], 12 | ignorePatterns: ["dist", ".eslintrc.cjs"], 13 | parser: "@typescript-eslint/parser", 14 | parserOptions: { 15 | ecmaFeatures: { 16 | jsx: true, 17 | }, 18 | ecmaVersion: 12, 19 | sourceType: "module", 20 | }, 21 | plugins: ["react-refresh", "react", "@typescript-eslint", "react-hooks"], 22 | rules: { 23 | "react-refresh/only-export-components": [ 24 | "warn", 25 | { allowConstantExport: true }, 26 | ], 27 | "no-use-before-define": "off", 28 | "@typescript-eslint/no-use-before-define": ["error"], 29 | "react/jsx-filename-extension": ["warn", { extensions: [".tsx"] }], 30 | "import/extensions": [ 31 | "error", 32 | "ignorePackages", 33 | { ts: "never", tsx: "never" }, 34 | ], 35 | "no-shadow": "off", 36 | "@typescript-eslint/no-shadow": ["error"], 37 | "@typescript-eslint/explicit-function-return-type": [ 38 | "error", 39 | { allowExpressions: true }, 40 | ], 41 | "@typescript-eslint/no-explicit-any": "off", 42 | "max-len": ["warn", { code: 120, ignoreComments: true, ignoreUrls: true }], 43 | "react-hooks/rules-of-hooks": "error", 44 | "react-hooks/exhaustive-deps": "warn", 45 | "import/prefer-default-export": "off", 46 | "react/prop-types": "off", 47 | "prettier/prettier": ["error", { endOfLine: "auto" }], 48 | }, 49 | settings: { 50 | "import/resolver": { 51 | typescript: {}, 52 | }, 53 | }, 54 | } 55 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Code owner settings for `litestar fullstack` 2 | # @maintainers should be assigned to all reviews. 3 | # Most specific assignment takes precedence though, so if you add a more specific thing than the `*` glob, you must also add @maintainers 4 | # For more info about code owners see https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-file-example 5 | 6 | # Global Assignment 7 | * @cofin @litestar-org/maintainers @litestar-org/members 8 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Tests and Linting 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | concurrency: 9 | group: test-${{ github.head_ref }} 10 | cancel-in-progress: true 11 | 12 | env: 13 | PYTHONUNBUFFERED: "1" 14 | FORCE_COLOR: "1" 15 | UV_LOCKED: 1 16 | 17 | jobs: 18 | validate: 19 | runs-on: ubuntu-latest 20 | env: 21 | SETUPTOOLS_USE_DISTUTILS: stdlib 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - uses: actions/setup-python@v5 26 | with: 27 | python-version: "3.11" 28 | 29 | - name: Install base libraries 30 | run: pip install nodeenv cython setuptools pip --upgrade --quiet --user 31 | 32 | - uses: pre-commit/action@v3.0.1 33 | 34 | test: 35 | needs: validate 36 | runs-on: ubuntu-latest 37 | strategy: 38 | fail-fast: true 39 | matrix: 40 | python-version: ["3.11","3.12","3.13"] 41 | steps: 42 | - name: Check out repository 43 | uses: actions/checkout@v4 44 | 45 | - name: Set up python ${{ matrix.python-version }} 46 | uses: actions/setup-python@v5 47 | with: 48 | python-version: ${{ matrix.python-version }} 49 | 50 | - name: Create cache file 51 | run: echo '${{ matrix.python-version }}' > ./matrix-file.txt 52 | 53 | - name: Install uv 54 | uses: astral-sh/setup-uv@v6 55 | with: 56 | version: "0.5.10" 57 | enable-cache: true 58 | 59 | - name: Install base libraries 60 | run: pip install nodeenv cython setuptools pip --upgrade --quiet --user 61 | 62 | - name: Install dependencies 63 | run: uv sync --all-groups 64 | 65 | - name: Test with Coverage 66 | run: uv run pytest tests --cov=app --cov-report=xml 67 | 68 | - if: matrix.python-version == '3.13' 69 | uses: actions/upload-artifact@v4 70 | with: 71 | name: coverage-xml 72 | path: coverage.xml 73 | 74 | build-docs: 75 | needs: 76 | - validate 77 | if: github.event_name == 'pull_request' 78 | runs-on: ubuntu-latest 79 | steps: 80 | - name: Check out repository 81 | uses: actions/checkout@v4 82 | 83 | - name: Set up Python 84 | uses: actions/setup-python@v5 85 | with: 86 | python-version: "3.12" 87 | 88 | - name: Install uv 89 | uses: astral-sh/setup-uv@v6 90 | with: 91 | version: "0.5.10" 92 | enable-cache: true 93 | 94 | - name: Install dependencies 95 | run: uv sync --all-groups 96 | 97 | - name: Build docs 98 | run: uv run make docs 99 | 100 | - name: Save PR number 101 | env: 102 | PR_NUMBER: ${{ github.event.number }} 103 | run: echo $PR_NUMBER > .pr_number 104 | 105 | - name: Upload artifact 106 | uses: actions/upload-artifact@v4 107 | with: 108 | name: docs-preview 109 | path: | 110 | docs/_build/html 111 | .pr_number 112 | 113 | codeql: 114 | needs: test 115 | runs-on: ubuntu-latest 116 | permissions: 117 | security-events: write 118 | steps: 119 | - name: Initialize CodeQL 120 | uses: github/codeql-action/init@v3 121 | with: 122 | languages: python 123 | - name: Checkout repository 124 | uses: actions/checkout@v4 125 | - name: Install base libraries 126 | run: pip install nodeenv cython setuptools pip --upgrade --quiet --user 127 | 128 | - name: Install uv 129 | uses: astral-sh/setup-uv@v6 130 | with: 131 | version: "0.5.10" 132 | enable-cache: true 133 | 134 | - name: Install dependencies 135 | run: uv sync --all-groups 136 | 137 | - name: Perform CodeQL Analysis 138 | uses: github/codeql-action/analyze@v3 139 | -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation Building 2 | 3 | on: 4 | release: 5 | types: [published] 6 | push: 7 | branches: 8 | - main 9 | 10 | env: 11 | UV_LOCKED: 1 12 | 13 | jobs: 14 | docs: 15 | permissions: 16 | contents: write 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - uses: actions/setup-python@v5 22 | with: 23 | python-version: "3.11" 24 | 25 | - name: Install uv 26 | uses: astral-sh/setup-uv@v6 27 | with: 28 | version: "0.5.10" 29 | enable-cache: true 30 | 31 | - name: Install dependencies 32 | run: uv sync --all-groups 33 | 34 | - name: Fetch gh pages 35 | run: git fetch origin gh-pages --depth=1 36 | 37 | - name: Build release docs 38 | run: uv run python tools/build_docs.py docs-build 39 | if: github.event_name == 'release' 40 | 41 | - name: Build dev docs 42 | run: uv run python tools/build_docs.py docs-build 43 | if: github.event_name == 'push' 44 | 45 | - name: Deploy 46 | uses: JamesIves/github-pages-deploy-action@v4 47 | with: 48 | folder: docs-build 49 | -------------------------------------------------------------------------------- /.github/workflows/pr-title.yaml: -------------------------------------------------------------------------------- 1 | name: "Lint PR Title" 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | permissions: 11 | pull-requests: read 12 | 13 | jobs: 14 | main: 15 | name: Validate PR title 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: amannn/action-semantic-pull-request@v5 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | parts/ 18 | sdist/ 19 | var/ 20 | wheels/ 21 | pip-wheel-metadata/ 22 | share/python-wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | poetry.toml 28 | .pdm-python 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | .python-version 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 94 | __pypackages__/ 95 | 96 | # Celery stuff 97 | celerybeat-schedule 98 | celerybeat.pid 99 | 100 | # SageMath parsed files 101 | *.sage.py 102 | 103 | # Environments 104 | .env* 105 | !.env.*.example 106 | !.env.testing 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | .venv 114 | media/ 115 | !media/.gitkeep 116 | 117 | # Spyder project settings 118 | .spyderproject 119 | .spyproject 120 | 121 | # Rope project settings 122 | .ropeproject 123 | 124 | # mkdocs documentation 125 | /site 126 | 127 | # mypy 128 | .mypy_cache/ 129 | .dmypy.json 130 | dmypy.json 131 | 132 | # Pyre type checker 133 | .pyre/ 134 | 135 | # vscode 136 | # .vscode 137 | .venv 138 | 139 | # Logs 140 | logs 141 | *.log 142 | npm-debug.log* 143 | yarn-debug.log* 144 | yarn-error.log* 145 | pnpm-debug.log* 146 | lerna-debug.log* 147 | 148 | node_modules 149 | dist 150 | dist-ssr 151 | *.local 152 | tsconfig.tsbuildinfo 153 | 154 | # Editor directories and files 155 | .vscode/* 156 | !.vscode/extensions.json 157 | .idea 158 | .DS_Store 159 | *.suo 160 | *.ntvs* 161 | *.njsproj 162 | *.sln 163 | *.sw? 164 | 165 | # temporary files 166 | tmp/ 167 | temp/ 168 | 169 | # built files from the web UI 170 | src/app/domain/web/public 171 | src/app/domain/web/public/hot 172 | .vite 173 | src/app/domain/web/static 174 | public/hot 175 | public/bundle 176 | pdm-pythn 177 | 178 | db.duckdb 179 | local.duckdb 180 | 181 | requirements.txt 182 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: "3" 3 | repos: 4 | - repo: https://github.com/compilerla/conventional-pre-commit 5 | rev: v4.0.0 6 | hooks: 7 | - id: conventional-pre-commit 8 | stages: [commit-msg] 9 | - repo: https://github.com/pre-commit/pre-commit-hooks 10 | rev: v5.0.0 11 | hooks: 12 | - id: check-ast 13 | - id: check-case-conflict 14 | - id: check-toml 15 | - id: debug-statements 16 | - id: end-of-file-fixer 17 | - id: mixed-line-ending 18 | - id: trailing-whitespace 19 | - repo: https://github.com/charliermarsh/ruff-pre-commit 20 | rev: "v0.9.10" 21 | hooks: 22 | # Run the linter. 23 | - id: ruff 24 | types_or: [ python, pyi ] 25 | args: [ --fix ] 26 | # Run the formatter. 27 | - id: ruff-format 28 | types_or: [ python, pyi ] 29 | - repo: https://github.com/codespell-project/codespell 30 | rev: v2.4.1 31 | hooks: 32 | - id: codespell 33 | exclude: "uv.lock|package.json|package-lock.json" 34 | additional_dependencies: 35 | - tomli 36 | - repo: https://github.com/sphinx-contrib/sphinx-lint 37 | rev: "v1.0.0" 38 | hooks: 39 | - id: sphinx-lint 40 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | templates 2 | scripts 3 | artwork 4 | deploy 5 | docs 6 | *.json 7 | .eslintrc.cjs 8 | postcss.config.cjs 9 | .github 10 | .venv 11 | media 12 | public 13 | dist 14 | .git 15 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": false, 6 | "endOfLine": "auto" 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "mikestead.dotenv", 4 | "christian-kohler.path-intellisense", 5 | "ms-python.vscode-pylance", 6 | "ms-python.python", 7 | "charliermarsh.ruff", 8 | "ms-python.mypy-type-checker" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/._*": true, 4 | "**/*.pyc": { 5 | "when": "$(basename).py" 6 | }, 7 | ".mypy_cache": true, 8 | "**/__pycache__": true, 9 | ".venv": false, 10 | ".idea": true, 11 | ".run": true, 12 | ".pytest_cache": true, 13 | ".hypothesis": true, 14 | ".nova": true, 15 | ".cache": true, 16 | ".dist": true, 17 | "**/.pytest_cache": true, 18 | "site": true, 19 | ".angular": true, 20 | ".ruff_cache": true, 21 | ".coverage": true, 22 | "node_modules": false 23 | }, 24 | "ruff.format.args": ["--config=${workspaceFolder}/pyproject.toml"], 25 | "ruff.lint.run": "onType", 26 | "ruff.lint.args": ["--config=${workspaceFolder}/pyproject.toml"], 27 | "mypy-type-checker.importStrategy": "fromEnvironment", 28 | "black-formatter.importStrategy": "fromEnvironment", 29 | "pylint.importStrategy": "fromEnvironment", 30 | "pylint.args": [ "--rcfile=pylintrc"], 31 | "python.autoComplete.extraPaths": ["${workspaceFolder}/src"], 32 | "python.terminal.activateEnvInCurrentTerminal": true, 33 | "python.terminal.executeInFileDir": true, 34 | "python.testing.pytestEnabled": true, 35 | "autoDocstring.guessTypes": false, 36 | "python.analysis.autoImportCompletions": true, 37 | "python.analysis.autoFormatStrings": true, 38 | "python.analysis.extraPaths": ["${workspaceFolder}/src"], 39 | "editor.formatOnSave": true, 40 | "notebook.formatOnSave.enabled": true, 41 | "black-formatter.args": ["--line-length=120"], 42 | "evenBetterToml.formatter.reorderKeys": true, 43 | "evenBetterToml.formatter.trailingNewline": true, 44 | "evenBetterToml.formatter.columnWidth": 120, 45 | "evenBetterToml.formatter.arrayAutoCollapse": true, 46 | "python.globalModuleInstallation": false, 47 | "python.testing.unittestEnabled": false, 48 | "python.testing.autoTestDiscoverOnSaveEnabled": true, 49 | "editor.codeActionsOnSave": { 50 | "source.fixAll.ruff": "explicit", 51 | "source.organizeImports.ruff": "explicit" 52 | }, 53 | "[python]": { 54 | "editor.formatOnSave": true, 55 | "editor.formatOnSaveMode": "file", 56 | "editor.insertSpaces": true, 57 | "editor.tabSize": 4, 58 | "editor.trimAutoWhitespace": true, 59 | "editor.defaultFormatter": "charliermarsh.ruff", 60 | "editor.codeActionsOnSave": { 61 | "source.fixAll": "explicit", 62 | "source.organizeImports": "explicit" 63 | } 64 | }, 65 | "python.analysis.fixAll": [ 66 | "source.unusedImports", 67 | "source.convertImportFormat" 68 | ], 69 | "sqltools.disableReleaseNotifications": true, 70 | "sqltools.disableNodeDetectNotifications": true, 71 | "python.testing.unittestArgs": [ 72 | "-v", 73 | "-s", 74 | "./tests", 75 | "-p", 76 | "test_*.py" 77 | ], 78 | "python.testing.pytestArgs": [ 79 | "tests" 80 | ], 81 | } 82 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021, 2022, 2023, 2024, 2025 Litestar Org. 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 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "resources/main.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /deploy/docker-compose.infra.yml: -------------------------------------------------------------------------------- 1 | services: 2 | cache: 3 | image: valkey/valkey:latest 4 | ports: 5 | - "16379:6379" 6 | hostname: cache 7 | command: redis-server --appendonly yes 8 | volumes: 9 | - cache-data:/data 10 | environment: 11 | ALLOW_EMPTY_PASSWORD: "yes" 12 | restart: unless-stopped 13 | logging: 14 | options: 15 | max-size: 10m 16 | max-file: "3" 17 | healthcheck: 18 | test: 19 | - CMD 20 | - redis-cli 21 | - ping 22 | interval: 1s 23 | timeout: 3s 24 | retries: 30 25 | db: 26 | image: postgres:latest 27 | ports: 28 | - "15432:5432" 29 | hostname: db 30 | environment: 31 | POSTGRES_PASSWORD: "app" 32 | POSTGRES_USER: "app" 33 | POSTGRES_DB: "app" 34 | volumes: 35 | - db-data:/var/lib/postgresql/data 36 | restart: unless-stopped 37 | logging: 38 | options: 39 | max-size: 10m 40 | max-file: "3" 41 | healthcheck: 42 | test: 43 | - CMD 44 | - pg_isready 45 | - -U 46 | - app 47 | interval: 2s 48 | timeout: 3s 49 | retries: 40 50 | volumes: 51 | db-data: {} 52 | cache-data: {} 53 | -------------------------------------------------------------------------------- /deploy/docker/dev/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PYTHON_BUILDER_IMAGE=3.13-slim-bookworm 2 | 3 | ## ---------------------------------------------------------------------------------- ## 4 | ## ------------------------- Python base -------------------------------------------- ## 5 | ## ---------------------------------------------------------------------------------- ## 6 | FROM python:${PYTHON_BUILDER_IMAGE} AS python-base 7 | ENV PIP_DEFAULT_TIMEOUT=100 \ 8 | PIP_DISABLE_PIP_VERSION_CHECK=1 \ 9 | PIP_NO_CACHE_DIR=1 \ 10 | PIP_ROOT_USER_ACTION=ignore \ 11 | PYTHONDONTWRITEBYTECODE=1 \ 12 | PYTHONUNBUFFERED=1 \ 13 | PYTHONFAULTHANDLER=1 \ 14 | PYTHONHASHSEED=random \ 15 | LANG=C.UTF-8 \ 16 | LC_ALL=C.UTF-8 17 | RUN apt-get update \ 18 | && apt-get upgrade -y \ 19 | && apt-get install -y --no-install-recommends git tini \ 20 | && apt-get autoremove -y \ 21 | && apt-get clean -y \ 22 | && rm -rf /root/.cache \ 23 | && rm -rf /var/apt/lists/* \ 24 | && rm -rf /var/cache/apt/* \ 25 | && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false\ 26 | && mkdir -p /workspace/app \ 27 | && pip install --quiet -U pip wheel setuptools virtualenv 28 | # Install uv 29 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ 30 | ## ---------------------------------------------------------------------------------- ## 31 | ## ------------------------- Python Dev Image --------------------------------------- ## 32 | ## ---------------------------------------------------------------------------------- ## 33 | FROM python-base AS dev-image 34 | ARG UV_INSTALL_ARGS="--all-groups" 35 | ARG ENV_SECRETS="runtime-secrets" 36 | ARG LITESTAR_APP="app.asgi:create_app" 37 | ARG VITE_USE_SERVER_LIFESPAN="true" 38 | ARG VITE_DEV_MODE="true" 39 | ARG VITE_HOT_RELOAD="true" 40 | ARG SAQ_USE_SERVER_LIFESPAN="false" 41 | ## --------------------------- standardize execution env ----------------------------- ## 42 | ENV PATH="/workspace/app/.venv/bin:/usr/local/bin:/opt/nodeenv/bin:$PATH" \ 43 | VIRTUAL_ENV="/workspace/app/.venv" \ 44 | ENV_SECRETS="${ENV_SECRETS}" \ 45 | VITE_USE_SERVER_LIFESPAN="${VITE_USE_SERVER_LIFESPAN}" \ 46 | VITE_DEV_MODE="${VITE_DEV_MODE}" \ 47 | VITE_HOT_RELOAD="${VITE_HOT_RELOAD}" \ 48 | SAQ_USE_SERVER_LIFESPAN="${SAQ_USE_SERVER_LIFESPAN}" \ 49 | PIP_DEFAULT_TIMEOUT=100 \ 50 | PIP_DISABLE_PIP_VERSION_CHECK=1 \ 51 | PIP_NO_CACHE_DIR=1 \ 52 | UV_LINK_MODE=copy \ 53 | UV_NO_CACHE=1 \ 54 | UV_COMPILE_BYTECODE=1 \ 55 | UV_INSTALL_ARGS="${UV_INSTALL_ARGS}" \ 56 | UV_SYSTEM_PYTHON=1 \ 57 | PYTHONDONTWRITEBYTECODE=1 \ 58 | PYTHONUNBUFFERED=1 \ 59 | PYTHONFAULTHANDLER=1 \ 60 | PYTHONHASHSEED=random \ 61 | LANG=C.UTF-8 \ 62 | LC_ALL=C.UTF-8 \ 63 | LITESTAR_APP="${LITESTAR_APP}" 64 | ## -------------------------- add build packages ----------------------------------- ## 65 | RUN apt-get install -y --no-install-recommends git build-essential curl \ 66 | && apt-get autoremove -y \ 67 | && apt-get clean -y \ 68 | && rm -rf /root/.cache \ 69 | && rm -rf /var/apt/lists/* \ 70 | && rm -rf /var/cache/apt/* \ 71 | && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false 72 | 73 | ## -------------------------- install application ----------------------------------- ## 74 | WORKDIR /workspace/app 75 | COPY pyproject.toml uv.lock README.md .pre-commit-config.yaml LICENSE Makefile \ 76 | package.json package-lock.json vite.config.ts tsconfig.json \ 77 | tailwind.config.cjs postcss.config.cjs components.json \ 78 | ./ 79 | COPY tools ./tools/ 80 | RUN uvx nodeenv --quiet /opt/nodeenv/ 81 | RUN NODE_OPTIONS="--no-deprecation --disable-warning=ExperimentalWarning" npm install --ignore-scripts --no-fund 82 | RUN uv sync ${UV_INSTALL_ARGS} --no-install-project 83 | 84 | COPY public ./public/ 85 | COPY resources ./resources/ 86 | COPY docs/ docs/ 87 | COPY tests/ tests/ 88 | COPY src src/ 89 | RUN uv sync $UV_INSTALL_ARGS 90 | 91 | STOPSIGNAL SIGINT 92 | EXPOSE 8000 93 | ENTRYPOINT ["tini","--" ] 94 | CMD [ "litestar","run","--host","0.0.0.0","--port","8000"] 95 | VOLUME /workspace/app 96 | -------------------------------------------------------------------------------- /docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | x-development-volumes: &development-volumes 2 | volumes: 3 | - ./docs:/workspace/app/docs/ 4 | - ./tests:/workspace/app/tests/ 5 | - ./src:/workspace/app/src/ 6 | - ./Makefile:/workspace/app/Makefile 7 | - ./pyproject.toml:/workspace/app/pyproject.toml 8 | - ./uv.lock:/workspace/app/uv.lock 9 | - ./tsconfig.json:/workspace/app/tsconfig.json 10 | - ./package.json:/workspace/app/package.json 11 | - ./package-lock.json:/workspace/app/package-lock.json 12 | - ./vite.config.ts:/workspace/app/vite.config.ts 13 | - ./resources:/workspace/app/resources 14 | - ./public:/workspace/app/public 15 | - ./components.json:/workspace/app/components.json 16 | - ./tailwind.config.cjs:/workspace/app/tailwind.config.cjs 17 | - ./postcss.config.cjs:/workspace/app/postcss.config.cjs 18 | - ./.env.docker.example:/workspace/app/.env 19 | 20 | services: 21 | app: 22 | build: 23 | context: . 24 | dockerfile: deploy/docker/dev/Dockerfile 25 | ports: 26 | - "8000:8000" 27 | - "3006:3006" 28 | tty: true 29 | environment: 30 | VITE_USE_SERVER_LIFESPAN: "true" # true in dev or run 31 | VITE_DEV_MODE: "true" 32 | VITE_HOT_RELOAD: "true" 33 | SAQ_USE_SERVER_LIFESPAN: "false" 34 | command: litestar run --reload --host 0.0.0.0 --port 8000 35 | restart: always 36 | <<: *development-volumes 37 | worker: 38 | build: 39 | context: . 40 | dockerfile: deploy/docker/dev/Dockerfile 41 | command: litestar workers run 42 | tty: true 43 | restart: always 44 | <<: *development-volumes 45 | depends_on: 46 | db: 47 | condition: service_healthy 48 | cache: 49 | condition: service_healthy 50 | 51 | env_file: 52 | - .env.docker.example 53 | migrator: 54 | build: 55 | context: . 56 | dockerfile: deploy/docker/dev/Dockerfile 57 | command: litestar database upgrade --no-prompt 58 | restart: "no" 59 | <<: *development-volumes 60 | env_file: 61 | - .env.docker.example 62 | depends_on: 63 | db: 64 | condition: service_healthy 65 | cache: 66 | condition: service_healthy 67 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | cache: 3 | image: valkey/valkey:latest 4 | ports: 5 | - "16379:6379" 6 | hostname: cache 7 | command: redis-server --appendonly yes 8 | volumes: 9 | - cache-data:/data 10 | environment: 11 | ALLOW_EMPTY_PASSWORD: "yes" 12 | restart: unless-stopped 13 | logging: 14 | options: 15 | max-size: 10m 16 | max-file: "3" 17 | healthcheck: 18 | test: 19 | - CMD 20 | - redis-cli 21 | - ping 22 | interval: 1s 23 | timeout: 3s 24 | retries: 30 25 | db: 26 | image: postgres:latest 27 | ports: 28 | - "15432:5432" 29 | hostname: db 30 | environment: 31 | POSTGRES_PASSWORD: "app" 32 | POSTGRES_USER: "app" 33 | POSTGRES_DB: "app" 34 | volumes: 35 | - db-data:/var/lib/postgresql/data 36 | restart: unless-stopped 37 | logging: 38 | options: 39 | max-size: 10m 40 | max-file: "3" 41 | healthcheck: 42 | test: 43 | - CMD 44 | - pg_isready 45 | - -U 46 | - app 47 | interval: 2s 48 | timeout: 3s 49 | retries: 40 50 | app: 51 | build: 52 | context: . 53 | dockerfile: deploy/docker/run/Dockerfile 54 | restart: always 55 | depends_on: 56 | db: 57 | condition: service_healthy 58 | cache: 59 | condition: service_healthy 60 | ports: 61 | - "8000:8000" 62 | environment: 63 | VITE_USE_SERVER_LIFESPAN: "false" # true if ssr or separate service 64 | SAQ_USE_SERVER_LIFESPAN: "false" 65 | env_file: 66 | - .env.docker.example 67 | worker: 68 | build: 69 | context: . 70 | dockerfile: deploy/docker/run/Dockerfile 71 | command: litestar workers run 72 | restart: always 73 | depends_on: 74 | db: 75 | condition: service_healthy 76 | cache: 77 | condition: service_healthy 78 | env_file: 79 | - .env.docker.example 80 | migrator: 81 | build: 82 | context: . 83 | dockerfile: deploy/docker/run/Dockerfile 84 | restart: "no" 85 | command: litestar database upgrade --no-prompt 86 | env_file: 87 | - .env.docker.example 88 | depends_on: 89 | db: 90 | condition: service_healthy 91 | cache: 92 | condition: service_healthy 93 | volumes: 94 | db-data: {} 95 | cache-data: {} 96 | -------------------------------------------------------------------------------- /docs/_static/badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/docs/_static/badge.png -------------------------------------------------------------------------------- /docs/_static/badge.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | 11 | 12 | 13 | 16 | 17 | 18 | 21 | 22 | 25 | 28 | 31 | 32 | -------------------------------------------------------------------------------- /docs/api/asgi.rst: -------------------------------------------------------------------------------- 1 | === 2 | app 3 | === 4 | 5 | Entry point for ASGI-compatible application. 6 | 7 | .. automodule:: app.asgi 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/api/cli.rst: -------------------------------------------------------------------------------- 1 | === 2 | cli 3 | === 4 | 5 | Command line interface for the application. 6 | 7 | Litestar CLI 8 | ------------ 9 | 10 | .. click:: litestar.cli:litestar_group 11 | :prog: app 12 | :nested: full 13 | 14 | Database CLI 15 | ^^^^^^^^^^^^ 16 | 17 | .. click:: advanced_alchemy.extensions.litestar.cli:database_group 18 | :prog: app database 19 | :nested: full 20 | 21 | User management CLI 22 | ^^^^^^^^^^^^^^^^^^^ 23 | 24 | .. click:: app.cli.commands:user_management_group 25 | :prog: app user 26 | :nested: full 27 | -------------------------------------------------------------------------------- /docs/api/config.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | config 3 | ======== 4 | 5 | Documentation for the config module. 6 | 7 | .. automodule:: app.config 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/api/db.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | db 3 | ======== 4 | 5 | Documentation for the db module. 6 | 7 | .. automodule:: app.db 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/api/domain/accounts/controllers/access.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | access 3 | ====== 4 | 5 | Controllers for the access routes for the accounts domain. 6 | 7 | .. automodule:: app.domain.accounts.controllers.access 8 | :members: 9 | :no-index: 10 | -------------------------------------------------------------------------------- /docs/api/domain/accounts/controllers/accounts.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | accounts 3 | ======== 4 | 5 | Controllers for the accounts routes for the accounts domain. 6 | 7 | .. automodule:: app.domain.accounts.controllers 8 | :members: UserController UserRoleController RoleController 9 | -------------------------------------------------------------------------------- /docs/api/domain/accounts/controllers/index.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | accounts 3 | ======== 4 | 5 | .. toctree:: 6 | :titlesonly: 7 | :caption: Accounts Controllers API Reference 8 | :glob: 9 | :hidden: 10 | 11 | * 12 | -------------------------------------------------------------------------------- /docs/api/domain/accounts/controllers/users.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | controllers 3 | =========== 4 | 5 | Controllers for the user accounts. 6 | 7 | .. automodule:: app.domain.accounts.controllers.users 8 | :members: 9 | :no-index: 10 | -------------------------------------------------------------------------------- /docs/api/domain/accounts/deps.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | deps 3 | ======== 4 | 5 | Dependencies for the accounts domain. 6 | 7 | .. automodule:: app.domain.accounts.deps 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/api/domain/accounts/guards.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | guards 3 | ====== 4 | 5 | Guards for the accounts domain. 6 | 7 | .. automodule:: app.domain.accounts.guards 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/api/domain/accounts/index.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | accounts 3 | ======== 4 | 5 | .. toctree:: 6 | :titlesonly: 7 | :caption: Accounts Domain API Reference 8 | :glob: 9 | :hidden: 10 | 11 | * 12 | controllers/index 13 | -------------------------------------------------------------------------------- /docs/api/domain/accounts/services.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | services 3 | ======== 4 | 5 | Services for the accounts domain. 6 | 7 | .. automodule:: app.domain.accounts.services 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/api/domain/index.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | domain 3 | ====== 4 | 5 | 6 | .. toctree:: 7 | :titlesonly: 8 | :caption: Domain API Reference 9 | :hidden: 10 | 11 | accounts/index 12 | teams/index 13 | tags/index 14 | web/index 15 | system/index 16 | -------------------------------------------------------------------------------- /docs/api/domain/system/controllers.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | controllers 3 | =========== 4 | 5 | Controllers for the application system. 6 | 7 | .. automodule:: app.domain.system.controllers 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/api/domain/system/index.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | system 3 | ====== 4 | 5 | .. toctree:: 6 | :titlesonly: 7 | :caption: System Domain API Reference 8 | :glob: 9 | :hidden: 10 | 11 | * 12 | -------------------------------------------------------------------------------- /docs/api/domain/system/tasks.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | tasks 3 | ===== 4 | 5 | Tasks for the application system. 6 | 7 | .. automodule:: app.domain.system.tasks 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/api/domain/tags/controllers.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | controllers 3 | =========== 4 | 5 | Controllers for the tags domain. 6 | 7 | .. automodule:: app.domain.tags.controllers 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/api/domain/tags/index.rst: -------------------------------------------------------------------------------- 1 | ==== 2 | tags 3 | ==== 4 | 5 | .. toctree:: 6 | :titlesonly: 7 | :caption: Tags Domain API Reference 8 | :glob: 9 | :hidden: 10 | 11 | * 12 | -------------------------------------------------------------------------------- /docs/api/domain/tags/services.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | services 3 | ======== 4 | 5 | Services for the tags domain. 6 | 7 | .. automodule:: app.domain.tags.services 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/api/domain/teams/controllers/index.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | teams 3 | ===== 4 | 5 | .. toctree:: 6 | :titlesonly: 7 | :caption: Teams Controllers API Reference 8 | :glob: 9 | :hidden: 10 | 11 | * 12 | -------------------------------------------------------------------------------- /docs/api/domain/teams/controllers/team_invitation.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | team invitation 3 | =============== 4 | 5 | Controllers for the team invitiation routes for the teams domain. 6 | 7 | .. automodule:: app.domain.teams.controllers.team_invitation 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/api/domain/teams/controllers/team_member.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | team member 3 | =========== 4 | 5 | Controllers for the team member routes for the teams domain. 6 | 7 | .. automodule:: app.domain.teams.controllers.team_member 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/api/domain/teams/controllers/teams.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | teams 3 | ===== 4 | 5 | Controllers for the teams routes for the teams domain. 6 | 7 | .. automodule:: app.domain.teams.controllers.teams 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/api/domain/teams/guards.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | guards 3 | ====== 4 | 5 | Guards for the teams domain. 6 | 7 | .. automodule:: app.domain.teams.guards 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/api/domain/teams/index.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | teams 3 | ===== 4 | 5 | .. toctree:: 6 | :titlesonly: 7 | :caption: Teams Domain API Reference 8 | :glob: 9 | :hidden: 10 | 11 | * 12 | controllers/index 13 | -------------------------------------------------------------------------------- /docs/api/domain/teams/services.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | services 3 | ======== 4 | 5 | Services for the teams domain. 6 | 7 | .. automodule:: app.domain.teams.services 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/api/domain/web/controllers.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | controllers 3 | =========== 4 | 5 | Controllers for the application web interface. 6 | 7 | .. automodule:: app.domain.web.controllers 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/api/domain/web/index.rst: -------------------------------------------------------------------------------- 1 | === 2 | web 3 | === 4 | 5 | .. toctree:: 6 | :titlesonly: 7 | :caption: Web Domain API Reference 8 | :glob: 9 | :hidden: 10 | 11 | * 12 | -------------------------------------------------------------------------------- /docs/api/index.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | API Reference 3 | ============= 4 | 5 | .. toctree:: 6 | :titlesonly: 7 | :caption: Application Core Components 8 | :hidden: 9 | 10 | cli 11 | asgi 12 | server 13 | config 14 | db 15 | lib/index 16 | 17 | .. toctree:: 18 | :titlesonly: 19 | :caption: Domain API Reference 20 | :hidden: 21 | 22 | domain/index 23 | -------------------------------------------------------------------------------- /docs/api/lib/crypt.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | crypt 3 | ===== 4 | 5 | Application cryptography configuration 6 | 7 | .. automodule:: app.lib.crypt 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/api/lib/deps.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | deps 3 | ======== 4 | 5 | Documentation for the deps module. 6 | 7 | .. automodule:: app.lib.deps 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/api/lib/dto.rst: -------------------------------------------------------------------------------- 1 | === 2 | dto 3 | === 4 | 5 | Application DTO configuration 6 | 7 | .. automodule:: app.lib.dto 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/api/lib/exceptions.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | exceptions 3 | ========== 4 | 5 | Application exceptions 6 | 7 | .. automodule:: app.lib.exceptions 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/api/lib/index.rst: -------------------------------------------------------------------------------- 1 | === 2 | lib 3 | === 4 | 5 | .. toctree:: 6 | :titlesonly: 7 | :caption: Library API Reference 8 | :glob: 9 | :hidden: 10 | 11 | * 12 | -------------------------------------------------------------------------------- /docs/api/lib/schema.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | schema 3 | ======== 4 | 5 | Documentation for the schema module. 6 | 7 | .. automodule:: app.lib.schema 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/api/server.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | server 3 | ======== 4 | 5 | Documentation for the server module. 6 | 7 | .. automodule:: app.server 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | """Sphinx configuration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import importlib.metadata 6 | import warnings 7 | from functools import partial 8 | from typing import TYPE_CHECKING, Any 9 | 10 | from sqlalchemy.exc import SAWarning 11 | 12 | if TYPE_CHECKING: 13 | from sphinx.addnodes import document 14 | from sphinx.application import Sphinx 15 | 16 | warnings.filterwarnings("ignore", category=SAWarning) 17 | warnings.filterwarnings("ignore", category=DeprecationWarning) # RemovedInSphinx80Warning 18 | 19 | # -- Project information ----------------------------------------------------- 20 | project = importlib.metadata.metadata("app")["Name"] 21 | copyright = "2025, Litestar Organization" 22 | author = "Cody Fincher" 23 | release = importlib.metadata.version("app") 24 | 25 | # -- General configuration --------------------------------------------------- 26 | extensions = [ 27 | "sphinx_click", 28 | "sphinx_design", 29 | "sphinx.ext.todo", 30 | "sphinx_copybutton", 31 | "sphinx.ext.autodoc", 32 | "sphinx.ext.viewcode", 33 | "sphinx.ext.napoleon", 34 | "sphinxcontrib.mermaid", 35 | "sphinx.ext.intersphinx", 36 | "sphinx_toolbox.collapse", 37 | "sphinx.ext.autosectionlabel", 38 | ] 39 | 40 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 41 | 42 | intersphinx_mapping = { 43 | "python": ("https://docs.python.org/3", None), 44 | "anyio": ("https://anyio.readthedocs.io/en/stable/", None), 45 | "click": ("https://click.palletsprojects.com/en/8.1.x/", None), 46 | "structlog": ("https://www.structlog.org/en/stable/", None), 47 | "litestar": ("https://docs.litestar.dev/latest/", None), 48 | "msgspec": ("https://jcristharif.com/msgspec/", None), 49 | "saq": ("https://saq-py.readthedocs.io/en/latest/", None), 50 | "advanced-alchemy": ("https://docs.advanced-alchemy.litestar.dev/latest/", None), 51 | } 52 | 53 | napoleon_google_docstring = True 54 | napoleon_include_special_with_doc = True 55 | napoleon_use_admonition_for_examples = True 56 | napoleon_use_admonition_for_notes = True 57 | napoleon_use_admonition_for_references = False 58 | napoleon_attr_annotations = True 59 | 60 | autoclass_content = "both" 61 | autodoc_default_options = { 62 | "members": True, 63 | "member-order": "bysource", 64 | "special-members": "__init__", 65 | "exclude-members": "__weakref__", 66 | "show-inheritance": True, 67 | "class-signature": "separated", 68 | "typehints-format": "short", 69 | } 70 | 71 | autosectionlabel_prefix_document = True 72 | suppress_warnings = [ 73 | "autosectionlabel.*", 74 | "ref.python", # TODO: remove when https://github.com/sphinx-doc/sphinx/issues/4961 is fixed 75 | ] 76 | todo_include_todos = True 77 | 78 | # -- Style configuration ----------------------------------------------------- 79 | html_theme = "shibuya" 80 | html_static_path = ["_static"] 81 | html_show_sourcelink = True 82 | html_title = "Litestar Fullstack Docs" 83 | html_context = { 84 | "github_user": "litestar-org", 85 | "github_repo": "litestar-fullstack", 86 | "github_version": "main", 87 | "doc_path": "docs", 88 | } 89 | 90 | 91 | def update_html_context( 92 | app: Sphinx, 93 | pagename: str, 94 | templatename: str, 95 | context: dict[str, Any], 96 | doctree: document, 97 | ) -> None: 98 | if "generate_toctree_html" in context: 99 | context["generate_toctree_html"] = partial(context["generate_toctree_html"], startdepth=0) 100 | 101 | 102 | def setup(app: Sphinx) -> dict[str, bool]: 103 | app.setup_extension("shibuya") 104 | app.connect("html-page-context", update_html_context) 105 | 106 | return {"parallel_read_safe": True, "parallel_write_safe": True} 107 | -------------------------------------------------------------------------------- /docs/contribution-guide.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | .. include:: ../CONTRIBUTING.rst 4 | 5 | Setting up the environment 6 | -------------------------- 7 | 8 | 1. If you do not have already have Astral's UV installed, run `make install-uv` 9 | 2. Run ``uv sync --all-groups`` to create a `virtual environment `_ and install 10 | the dependencies 11 | 3. If you're working on the documentation and need to build it locally, install the extra dependencies with ``uv sync --group docs`` 12 | 4. Install `pre-commit `_ 13 | 5. Run ``pre-commit install`` to install pre-commit hooks 14 | 15 | **Note** There is a short-cut for running the installation process. You can run ``make install`` to install the dependencies and pre-commit hooks. 16 | 17 | Code contributions 18 | ------------------ 19 | 20 | Workflow 21 | ++++++++ 22 | 23 | 1. `Fork `_ the `fullstack repository `_ 24 | 2. Clone your fork locally with git 25 | 3. `Set up the environment <#setting-up-the-environment>`_ 26 | 4. Make your changes 27 | 5. (Optional) Run ``make lint`` to run linters and formatters. This step is optional and will be executed 28 | automatically by git before you make a commit, but you may want to run it manually in order to apply fixes 29 | 6. Commit your changes to git 30 | 7. Push the changes to your fork 31 | 8. Open a `pull request `_. Give the pull request a descriptive title 32 | indicating what it changes. If it has a corresponding open issue. 33 | For example a pull request that fixes issue ``bug: Increased stack size making it impossible to find needle`` 34 | could be titled ``fix: Make needles easier to find by applying fire to haystack`` 35 | 36 | .. tip:: Pull requests and commits all need to follow the 37 | `Conventional Commit format `_ 38 | 39 | Project documentation 40 | --------------------- 41 | 42 | The documentation is located in the ``/docs`` directory and is built with `ReST `_ 43 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | Litestar Fullstack 3 | ================== 4 | 5 | The Litestar Fullstack repository contains the reference code for a fully-capable, production-ready 6 | fullstack Litestar web application. It is intended to be used as a starting point for new projects 7 | and as a reference for how to build a large scale fullstack Litestar application. 8 | 9 | You can take pieces as needed, or use the entire thing as a starting point for your project. 10 | It includes the following capabilities out of the box: 11 | 12 | .. seealso:: It is built on the `Litestar `_, ReactJS, `Vite `_, 13 | :doc:`SAQ `, `TailwindCSS `_ and comes with great features to reference: 14 | 15 | - User creation, authentication, and authorization via `UserController` and `AccessController` 16 | - Endpoints for listing, creating, updating, and deleting users 17 | - Login, logout, and signup functionalities with OAuth2 support 18 | - Profile management for authenticated users 19 | - Role-based access control using `RoleService` and guards 20 | - Job/Task Queues via :doc:`SAQ ` 21 | - Fully featured frontend stack with ReactJS (supports Vue, Angular, and all other JS frameworks) and native Vite integration via 22 | the `litestar-vite `_ plugin 23 | - Fully featured backend API with Litestar 24 | - Includes the utilization of :doc:`Guards ` and team-based authentication, 25 | - Extensive CLI 26 | - Advanced logging with :doc:`structlog ` 27 | - SQLAlchemy ORMs, including the :doc:`Advanced Alchemy ` helper library by `Jolt `_ 28 | - UUIDv7 based Primary Keys using `uuid-utils` 29 | - AioSQL for raw queries without the ORM 30 | - Alembic migrations 31 | - Dockerized development and production environments 32 | - Test suite 33 | 34 | Installation 35 | ------------ 36 | 37 | To get started, check out :doc:`the installation guide `. 38 | 39 | Usage 40 | ----- 41 | 42 | To see how to use the Litestar Fullstack, check out :doc:`the usage guide `. 43 | 44 | Reference 45 | --------- 46 | 47 | We also provide an API reference which can be found at :doc:`api/index`. 48 | 49 | .. toctree:: 50 | :titlesonly: 51 | :caption: Documentation 52 | :hidden: 53 | 54 | usage/index 55 | api/index 56 | 57 | .. toctree:: 58 | :titlesonly: 59 | :caption: Development 60 | :hidden: 61 | 62 | contribution-guide 63 | changelog 64 | -------------------------------------------------------------------------------- /docs/usage/development.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | Development 3 | =========== 4 | 5 | This section describes tips on developing using this example repository. 6 | 7 | Makefile 8 | -------- 9 | 10 | This repository includes a ``Makefile`` with common commands for development. 11 | 12 | 13 | Install Development Environment 14 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 15 | 16 | This command will remove any existing environment and install a new environment with the latest dependencies. 17 | 18 | .. code-block:: shell 19 | 20 | make install 21 | 22 | Upgrade Project Dependencies 23 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 24 | 25 | This command will upgrade all components of the application at the same time. It automatically executes: 26 | 27 | - ``uv lock --upgrade`` 28 | - ``npm update`` 29 | - ``pre-commit autoupdate`` 30 | 31 | .. code-block:: shell 32 | 33 | make upgrade 34 | 35 | Execute Pre-commit 36 | ^^^^^^^^^^^^^^^^^^ 37 | 38 | This command will automatically execute the pre-commit process for the project. 39 | 40 | .. code-block:: shell 41 | 42 | make lint 43 | 44 | Generate New Migrations 45 | ^^^^^^^^^^^^^^^^^^^^^^^ 46 | 47 | This command is a shorthand for executing ``app database make-migrations``. 48 | 49 | .. code-block:: shell 50 | 51 | make migrations 52 | 53 | Upgrade a Database to the Latest Revision 54 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 55 | 56 | This command is a shorthand for executing ``app database upgrade``. 57 | 58 | .. code-block:: shell 59 | 60 | make migrate 61 | 62 | Execute Full Test Suite 63 | ^^^^^^^^^^^^^^^^^^^^^^^ 64 | 65 | This command executes all tests for the project. 66 | 67 | .. code-block:: shell 68 | 69 | make test 70 | 71 | Full Makefile 72 | ------------- 73 | 74 | .. dropdown:: Full Makefile 75 | 76 | .. literalinclude:: ../../Makefile 77 | :language: make 78 | -------------------------------------------------------------------------------- /docs/usage/index.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | .. toctree:: 6 | :titlesonly: 7 | 8 | installation 9 | development 10 | startup 11 | -------------------------------------------------------------------------------- /docs/usage/installation.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Getting Started 3 | =============== 4 | 5 | The following is a guide to help you get this repository running. 6 | 7 | Setup 8 | ----- 9 | 10 | Most of the development-related tasks are included in the ``Makefile`` (See: :doc:`development`). 11 | To install an environment, with all development packages run: 12 | 13 | .. code-block:: bash 14 | 15 | make install 16 | 17 | This command does the following: 18 | 19 | - Install ``uv`` if it is not available in the path. 20 | - Create a virtual environment with all dependencies configured 21 | - Build assets to be hosted by production asset server 22 | 23 | Edit ``.env`` configuration 24 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 25 | 26 | There is a sample ``.env`` file located in the root of the repository. 27 | 28 | .. code-block:: bash 29 | 30 | cp .env.example .env 31 | 32 | .. tip:: ``SECRET_KEY``, ``DATABASE_URI``, and ``REDIS_URL`` are the most important config settings. 33 | Be sure to set this properly. 34 | 35 | You can generate a ``SECRET_KEY`` by running: 36 | 37 | .. code-block:: bash 38 | 39 | ❯ openssl rand -base64 32 40 | 41 | +U9UcN0meCsxkShMINkqZ7pcwpEpOC9AwOArZI6mYDU= 42 | 43 | Deploy Database Migrations 44 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 45 | 46 | You can run most of the database commands with the integrated CLI tool. 47 | 48 | To deploy migration to the database, execute: 49 | 50 | .. code-block:: bash 51 | 52 | ❯ app database upgrade 53 | 2023-06-16T16:55:17.048183Z [info ] Context impl PostgresqlImpl. 54 | 2023-06-16T16:55:17.048251Z [info ] Will assume transactional DDL. 55 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | def start_app() -> None: 5 | """Application Management Entrypoint. 6 | 7 | This is here for convenience due to its ubiquitous usage in Django. 8 | 9 | This invokes the same as the `app` command (`python -m app`). 10 | """ 11 | import sys 12 | from pathlib import Path 13 | 14 | current_path = Path(__file__).parent.resolve() 15 | sys.path.append(str(current_path)) 16 | 17 | from app.__main__ import run_cli 18 | 19 | run_cli() 20 | 21 | 22 | if __name__ == "__main__": 23 | start_app() 24 | -------------------------------------------------------------------------------- /nixpacks.toml: -------------------------------------------------------------------------------- 1 | # https://nixpacks.com/docs/configuration/file 2 | 3 | providers = ['python', 'node'] # force python as the only provider, otherwise railway may think this is a node project 4 | 5 | # set up some variables to minimize annoyance 6 | [variables] 7 | LITESTAR_SKIP_NODEENV_INSTALL = 'true' # skip using nodeenv. nix handles that for us. 8 | NIXPACKS_PYTHON_VERSION = '3.13' # set python version to install 9 | NIXPACKS_UV_VERSION = '0.5.22' # set uv version to install 10 | NPM_CONFIG_FUND = 'false' # the fund notification is is also pretty useless in a production environment 11 | NPM_CONFIG_UPDATE_NOTIFIER = 'false' # the node update notification is relatively useless in a production environment 12 | PIP_DISABLE_PIP_VERSION_CHECK = '1' # the pip update notification is relatively useless in a production environment 13 | 14 | [phases.setup] 15 | nixPkgs = ['...'] # add nodejs since it is needed to build the frontend 16 | 17 | [phases.install] 18 | # cmds = [ 19 | # 'python -m venv --copies /opt/venv && . /opt/venv/bin/activate && pip install -U mypy cython setuptools uv==$NIXPACKS_UV_VERSION && uv sync --frozen --no-dev', 20 | # ] # custom install command allows for setting uv version above 21 | 22 | [start] 23 | cmd = 'app database upgrade --no-prompt && app run --wc 2 --host 0.0.0.0 --port $PORT' 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "vite", 5 | "build": "vite build", 6 | "watch": "vite build --watch" 7 | }, 8 | "dependencies": { 9 | "@heroicons/react": "^2.2.0", 10 | "@hookform/resolvers": "^3.10.0", 11 | "@radix-ui/react-avatar": "^1.1.2", 12 | "@radix-ui/react-checkbox": "^1.1.3", 13 | "@radix-ui/react-dialog": "^1.1.4", 14 | "@radix-ui/react-dropdown-menu": "^2.1.4", 15 | "@radix-ui/react-icons": "^1.3.2", 16 | "@radix-ui/react-label": "^2.1.1", 17 | "@radix-ui/react-popover": "^1.1.4", 18 | "@radix-ui/react-select": "^2.1.4", 19 | "@radix-ui/react-slot": "^1.1.1", 20 | "@radix-ui/react-toast": "^1.2.4", 21 | "@tanstack/react-query": "^5.64.2", 22 | "@tanstack/react-table": "^8.20.6", 23 | "class-variance-authority": "^0.7.1", 24 | "clsx": "^2.1.1", 25 | "cmdk": "^1.0.4", 26 | "jwt-decode": "^4.0.0", 27 | "litestar-vite-plugin": "^0.13.0", 28 | "next-themes": "^0.4.4", 29 | "react": "^19.0.0", 30 | "react-dom": "^19.0.0", 31 | "react-hook-form": "^7.54.2", 32 | "react-router-dom": "^7.1.3", 33 | "sonner": "^1.7.2", 34 | "tailwind-merge": "^2.6.0", 35 | "tailwindcss-animate": "^1.0.7", 36 | "zod": "^3.24.1" 37 | }, 38 | "type": "module", 39 | "devDependencies": { 40 | "@tailwindcss/forms": "^0.5.10", 41 | "@tailwindcss/typography": "^0.5.16", 42 | "@types/node": "^22.10.7", 43 | "@types/react": "^19.0.7", 44 | "@types/react-dom": "^19.0.3", 45 | "@typescript-eslint/eslint-plugin": "^8.21.0", 46 | "@typescript-eslint/parser": "^8.21.0", 47 | "@vitejs/plugin-react": "^4.3.4", 48 | "autoprefixer": "^10.4.20", 49 | "axios": "^1.7.9", 50 | "eslint": "^9.18.0", 51 | "eslint-config-prettier": "^10.0.1", 52 | "eslint-plugin-prettier": "^5.2.3", 53 | "eslint-plugin-react-hooks": "^5.1.0", 54 | "eslint-plugin-react-refresh": "^0.4.18", 55 | "postcss": "^8.5.1", 56 | "prettier": "^3.4.2", 57 | "tailwindcss": "^3.4.17", 58 | "typescript": "^5.7.3", 59 | "vite": "^6.0.10" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/public/favicon.png -------------------------------------------------------------------------------- /railway.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.up.railway.app/railway.schema.json", 3 | "build": { 4 | "builder": "NIXPACKS" 5 | }, 6 | "deploy": { 7 | "startCommand": "app database upgrade --no-prompt && app run --wc 2 --host 0.0.0.0 --port $PORT" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /resources/App.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes, useLocation, useNavigate } from "react-router-dom" 2 | // pages imports 3 | import ProtectedRoutes from "@/lib/protected-routes" 4 | import Placeholder from "@/pages/Placeholder" 5 | import Login from "@/pages/access/Login" 6 | import Register from "@/pages/access/Register" 7 | import Home from "@/pages/Home" 8 | import PageNotFound from "@/pages/PageNotFound" 9 | import { useAuth } from "@/contexts/AuthProvider" 10 | import { useEffect } from "react" 11 | import { ThemeProvider } from "@/components/theme-provider" 12 | 13 | const App: React.FC = () => { 14 | const navigate = useNavigate() 15 | const { auth } = useAuth() 16 | const { pathname } = useLocation() 17 | 18 | useEffect(() => { 19 | if (auth?.token && (pathname === "/login" || pathname === "/register")) { 20 | navigate("/") 21 | } 22 | }, [auth, pathname]) 23 | 24 | return ( 25 | 26 | 27 | }> 28 | } /> 29 | 30 | } /> 31 | } /> 32 | } /> 33 | } /> 34 | } /> 35 | } /> 36 | 37 | 38 | ) 39 | } 40 | 41 | export default App 42 | -------------------------------------------------------------------------------- /resources/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/resources/assets/.gitkeep -------------------------------------------------------------------------------- /resources/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/resources/assets/favicon.png -------------------------------------------------------------------------------- /resources/components/main-nav.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom" 2 | import { cn } from "@/lib/utils" 3 | 4 | export function MainNav({ 5 | className, 6 | ...props 7 | }: React.HTMLAttributes) { 8 | return ( 9 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /resources/components/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button" 2 | import { 3 | DropdownMenu, 4 | DropdownMenuContent, 5 | DropdownMenuItem, 6 | DropdownMenuTrigger, 7 | } from "@/components/ui/dropdown-menu" 8 | import { useTheme } from "@/components/theme-provider" 9 | 10 | export function ModeToggle() { 11 | const { setTheme } = useTheme() 12 | 13 | return ( 14 | 15 | 16 | 48 | 49 | 50 | setTheme("light")}> 51 | Light 52 | 53 | setTheme("dark")}> 54 | Dark 55 | 56 | setTheme("system")}> 57 | System 58 | 59 | 60 | 61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /resources/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect, useState } from "react" 2 | 3 | type Theme = "dark" | "light" | "system" 4 | 5 | type ThemeProviderProps = { 6 | children: React.ReactNode 7 | defaultTheme?: Theme 8 | storageKey?: string 9 | } 10 | 11 | type ThemeProviderState = { 12 | theme: Theme 13 | setTheme: (theme: Theme) => void 14 | } 15 | 16 | const initialState: ThemeProviderState = { 17 | theme: "system", 18 | setTheme: () => null, 19 | } 20 | 21 | const ThemeProviderContext = createContext(initialState) 22 | 23 | export function ThemeProvider({ 24 | children, 25 | defaultTheme = "system", 26 | storageKey = "litestar-fullstack-ui-theme", 27 | ...props 28 | }: ThemeProviderProps) { 29 | const [theme, setTheme] = useState( 30 | () => (localStorage.getItem(storageKey) as Theme) || defaultTheme 31 | ) 32 | 33 | useEffect(() => { 34 | const root = window.document.documentElement 35 | 36 | root.classList.remove("light", "dark") 37 | 38 | if (theme === "system") { 39 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") 40 | .matches 41 | ? "dark" 42 | : "light" 43 | 44 | root.classList.add(systemTheme) 45 | return 46 | } 47 | 48 | root.classList.add(theme) 49 | }, [theme]) 50 | 51 | const value = { 52 | theme, 53 | setTheme: (theme: Theme) => { 54 | localStorage.setItem(storageKey, theme) 55 | setTheme(theme) 56 | }, 57 | } 58 | 59 | return ( 60 | 61 | {children} 62 | 63 | ) 64 | } 65 | 66 | export const useTheme = () => { 67 | const context = useContext(ThemeProviderContext) 68 | 69 | if (context === undefined) { 70 | throw new Error("useTheme must be used within a ThemeProvider") 71 | } 72 | 73 | return context 74 | } 75 | -------------------------------------------------------------------------------- /resources/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Avatar = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | )) 19 | Avatar.displayName = AvatarPrimitive.Root.displayName 20 | 21 | const AvatarImage = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, ...props }, ref) => ( 25 | 30 | )) 31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 32 | 33 | const AvatarFallback = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, ...props }, ref) => ( 37 | 45 | )) 46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 47 | 48 | export { Avatar, AvatarImage, AvatarFallback } 49 | -------------------------------------------------------------------------------- /resources/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /resources/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /resources/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 3 | import { CheckIcon } from "@radix-ui/react-icons" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Checkbox = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 19 | 22 | 23 | 24 | 25 | )) 26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 27 | 28 | export { Checkbox } 29 | -------------------------------------------------------------------------------- /resources/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /resources/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const labelVariants = cva( 8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 9 | ) 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )) 22 | Label.displayName = LabelPrimitive.Root.displayName 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /resources/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as PopoverPrimitive from "@radix-ui/react-popover" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Popover = PopoverPrimitive.Root 7 | 8 | const PopoverTrigger = PopoverPrimitive.Trigger 9 | 10 | const PopoverAnchor = PopoverPrimitive.Anchor 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )) 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 30 | 31 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 32 | -------------------------------------------------------------------------------- /resources/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "next-themes" 2 | import { Toaster as Sonner } from "sonner" 3 | 4 | type ToasterProps = React.ComponentProps 5 | 6 | const Toaster = ({ ...props }: ToasterProps) => { 7 | const { theme = "system" } = useTheme() 8 | 9 | return ( 10 | 26 | ) 27 | } 28 | 29 | export { Toaster } 30 | -------------------------------------------------------------------------------- /resources/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Table = React.forwardRef< 6 | HTMLTableElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
10 | 15 | 16 | )) 17 | Table.displayName = "Table" 18 | 19 | const TableHeader = React.forwardRef< 20 | HTMLTableSectionElement, 21 | React.HTMLAttributes 22 | >(({ className, ...props }, ref) => ( 23 | 24 | )) 25 | TableHeader.displayName = "TableHeader" 26 | 27 | const TableBody = React.forwardRef< 28 | HTMLTableSectionElement, 29 | React.HTMLAttributes 30 | >(({ className, ...props }, ref) => ( 31 | 36 | )) 37 | TableBody.displayName = "TableBody" 38 | 39 | const TableFooter = React.forwardRef< 40 | HTMLTableSectionElement, 41 | React.HTMLAttributes 42 | >(({ className, ...props }, ref) => ( 43 | tr]:last:border-b-0", 47 | className 48 | )} 49 | {...props} 50 | /> 51 | )) 52 | TableFooter.displayName = "TableFooter" 53 | 54 | const TableRow = React.forwardRef< 55 | HTMLTableRowElement, 56 | React.HTMLAttributes 57 | >(({ className, ...props }, ref) => ( 58 | 66 | )) 67 | TableRow.displayName = "TableRow" 68 | 69 | const TableHead = React.forwardRef< 70 | HTMLTableCellElement, 71 | React.ThHTMLAttributes 72 | >(({ className, ...props }, ref) => ( 73 |
[role=checkbox]]:translate-y-[2px]", 77 | className 78 | )} 79 | {...props} 80 | /> 81 | )) 82 | TableHead.displayName = "TableHead" 83 | 84 | const TableCell = React.forwardRef< 85 | HTMLTableCellElement, 86 | React.TdHTMLAttributes 87 | >(({ className, ...props }, ref) => ( 88 | [role=checkbox]]:translate-y-[2px]", 92 | className 93 | )} 94 | {...props} 95 | /> 96 | )) 97 | TableCell.displayName = "TableCell" 98 | 99 | const TableCaption = React.forwardRef< 100 | HTMLTableCaptionElement, 101 | React.HTMLAttributes 102 | >(({ className, ...props }, ref) => ( 103 |
108 | )) 109 | TableCaption.displayName = "TableCaption" 110 | 111 | export { 112 | Table, 113 | TableHeader, 114 | TableBody, 115 | TableFooter, 116 | TableHead, 117 | TableRow, 118 | TableCell, 119 | TableCaption, 120 | } 121 | -------------------------------------------------------------------------------- /resources/components/user-nav.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" 2 | import { Button } from "@/components/ui/button" 3 | import { 4 | DropdownMenu, 5 | DropdownMenuContent, 6 | DropdownMenuGroup, 7 | DropdownMenuItem, 8 | DropdownMenuLabel, 9 | DropdownMenuSeparator, 10 | DropdownMenuShortcut, 11 | DropdownMenuTrigger, 12 | } from "@/components/ui/dropdown-menu" 13 | 14 | export function UserNav() { 15 | return ( 16 | 17 | 18 | 24 | 25 | 26 | 27 |
28 |

shadcn

29 |

30 | m@example.com 31 |

32 |
33 |
34 | 35 | 36 | 37 | Profile 38 | ⇧⌘P 39 | 40 | 41 | Billing 42 | ⌘B 43 | 44 | 45 | Settings 46 | ⌘S 47 | 48 | New Team 49 | 50 | 51 | 52 | Log out 53 | ⇧⌘Q 54 | 55 |
56 |
57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /resources/contexts/AuthProvider.tsx: -------------------------------------------------------------------------------- 1 | import { toast } from "sonner" 2 | import axios from "axios" 3 | import { jwtDecode } from "jwt-decode" 4 | import { createContext, useContext, useEffect, useState } from "react" 5 | import { useNavigate } from "react-router-dom" 6 | 7 | type User = { 8 | id: string 9 | name: string 10 | email: string 11 | createdAt: string 12 | } 13 | 14 | interface handleLoginProps { 15 | token: string 16 | user: User | null 17 | } 18 | 19 | interface AuthStateProps { 20 | user: User | null 21 | token: string 22 | } 23 | 24 | const AuthContext = createContext(null) 25 | 26 | export const useAuth = () => { 27 | return useContext(AuthContext) 28 | } 29 | 30 | const AuthProvider = ({ children }: { children: React.ReactNode }) => { 31 | const navigate = useNavigate() 32 | const [auth, setAuth] = useState({ 33 | user: null, 34 | token: "", 35 | }) 36 | 37 | axios.defaults.headers.common["Authorization"] = auth?.token || "" 38 | 39 | useEffect(() => { 40 | const storedAuth = JSON.parse(localStorage.getItem("auth")!) 41 | if (storedAuth) { 42 | const decodedToken = jwtDecode(storedAuth.token) 43 | const expiresAt = decodedToken?.exp 44 | const currentTime = Math.floor(Date.now() / 1000) 45 | if (expiresAt! <= currentTime) { 46 | setAuth({ user: null, token: "" }) 47 | localStorage.removeItem("auth") 48 | return 49 | } else { 50 | setAuth({ 51 | user: storedAuth.user, 52 | token: storedAuth.token, 53 | }) 54 | } 55 | } 56 | }, []) 57 | 58 | const handleLogin = ({ token, user }: handleLoginProps) => { 59 | setAuth({ user, token }) 60 | localStorage.setItem("auth", JSON.stringify({ user, token })) 61 | return 62 | } 63 | 64 | const handleLogout = () => { 65 | setAuth({ user: null, token: "" }) 66 | localStorage.removeItem("auth") 67 | toast("Logged out successfully") 68 | navigate("/login") 69 | return 70 | } 71 | 72 | return ( 73 | 81 | {children} 82 | 83 | ) 84 | } 85 | 86 | export default AuthProvider 87 | -------------------------------------------------------------------------------- /resources/layouts/AuthLayout.tsx: -------------------------------------------------------------------------------- 1 | import favicon from "@/assets/favicon.png" 2 | interface AuthLayoutProps { 3 | children: React.ReactNode 4 | title: string 5 | description: string 6 | keywords: string 7 | } 8 | 9 | const helmetContext = {} 10 | 11 | const AuthLayout = ({ 12 | children, 13 | title, 14 | description, 15 | keywords, 16 | }: AuthLayoutProps) => { 17 | return ( 18 | <> 19 | 20 | 21 | 22 | 23 | {title} 24 | {children} 25 | 26 | ) 27 | } 28 | 29 | AuthLayout.defaultProps = { 30 | title: "Litestar Fullstack Application", 31 | description: "A fullstack reference application", 32 | keywords: "litestar", 33 | } 34 | 35 | export default AuthLayout 36 | -------------------------------------------------------------------------------- /resources/layouts/MainLayout.tsx: -------------------------------------------------------------------------------- 1 | import favicon from "@/assets/favicon.png" 2 | interface MainLayoutProps { 3 | children: React.ReactNode 4 | title: string 5 | description: string 6 | keywords: string 7 | } 8 | 9 | const MainLayout = ({ 10 | children, 11 | title, 12 | description, 13 | keywords, 14 | }: MainLayoutProps) => { 15 | return ( 16 | <> 17 | 18 | 19 | 20 | 21 | {title} 22 |
23 |
{children}
24 |
25 | 26 | ) 27 | } 28 | 29 | MainLayout.defaultProps = { 30 | title: "Litestar Fullstack Application", 31 | description: "A fullstack reference application", 32 | keywords: "litestar", 33 | } 34 | 35 | export default MainLayout 36 | -------------------------------------------------------------------------------- /resources/lib/protected-routes.tsx: -------------------------------------------------------------------------------- 1 | import { useAuth } from "@/contexts/AuthProvider" 2 | import { useEffect } from "react" 3 | import { Outlet, useNavigate } from "react-router-dom" 4 | 5 | const ProtectedRoutes: React.FC = () => { 6 | const { auth } = useAuth() 7 | const navigate = useNavigate() 8 | 9 | useEffect(() => { 10 | if (!auth.token) { 11 | return navigate("/login") 12 | } 13 | }, [auth]) 14 | 15 | return 16 | } 17 | 18 | export default ProtectedRoutes 19 | -------------------------------------------------------------------------------- /resources/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /resources/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 240 10% 3.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 240 10% 3.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 240 10% 3.9%; 13 | --primary: 346.8 77.2% 49.8%; 14 | --primary-foreground: 355.7 100% 97.3%; 15 | --secondary: 240 4.8% 95.9%; 16 | --secondary-foreground: 240 5.9% 10%; 17 | --muted: 240 4.8% 95.9%; 18 | --muted-foreground: 240 3.8% 46.1%; 19 | --accent: 240 4.8% 95.9%; 20 | --accent-foreground: 240 5.9% 10%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 0 0% 98%; 23 | --border: 240 5.9% 90%; 24 | --input: 240 5.9% 90%; 25 | --ring: 346.8 77.2% 49.8%; 26 | --radius: 0.5rem; 27 | } 28 | 29 | .dark { 30 | --background: 20 14.3% 4.1%; 31 | --foreground: 0 0% 95%; 32 | --card: 24 9.8% 10%; 33 | --card-foreground: 0 0% 95%; 34 | --popover: 0 0% 9%; 35 | --popover-foreground: 0 0% 95%; 36 | --primary: 346.8 77.2% 49.8%; 37 | --primary-foreground: 355.7 100% 97.3%; 38 | --secondary: 240 3.7% 15.9%; 39 | --secondary-foreground: 0 0% 98%; 40 | --muted: 0 0% 15%; 41 | --muted-foreground: 240 5% 64.9%; 42 | --accent: 12 6.5% 15.1%; 43 | --accent-foreground: 0 0% 98%; 44 | --destructive: 0 62.8% 30.6%; 45 | --destructive-foreground: 0 85.7% 97.3%; 46 | --border: 240 3.7% 15.9%; 47 | --input: 240 3.7% 15.9%; 48 | --ring: 346.8 77.2% 49.8%; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /resources/main.tsx: -------------------------------------------------------------------------------- 1 | import "vite/modulepreload-polyfill" 2 | 3 | import React from "react" 4 | import ReactDOM from "react-dom/client" 5 | import App from "@/App.tsx" 6 | import "@/main.css" 7 | import { BrowserRouter } from "react-router-dom" 8 | import AuthProvider from "@/contexts/AuthProvider.tsx" 9 | import { Toaster } from "@/components/ui/sonner" 10 | 11 | ReactDOM.createRoot(document.getElementById("root")!).render( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ) 21 | -------------------------------------------------------------------------------- /resources/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import { useAuth } from "@/contexts/AuthProvider" 2 | import MainLayout from "@/layouts/MainLayout" 3 | import { useEffect } from "react" 4 | import { TeamSwitcher } from "@/components/team-switcher" 5 | import { MainNav } from "@/components/main-nav" 6 | import { UserNav } from "@/components/user-nav" 7 | 8 | const Home: React.FC = () => { 9 | const { auth } = useAuth() 10 | 11 | useEffect(() => {}, [auth?.token]) 12 | 13 | return ( 14 | 19 |
20 |
21 |
22 | 23 |
24 | 25 | 26 |
27 |
28 |
29 |
30 |
31 | ) 32 | } 33 | 34 | export default Home 35 | -------------------------------------------------------------------------------- /resources/pages/PageNotFound.tsx: -------------------------------------------------------------------------------- 1 | import MainLayout from "@/layouts/MainLayout" 2 | import { useNavigate } from "react-router-dom" 3 | 4 | const PageNotFound: React.FC = () => { 5 | const navigate = useNavigate() 6 | 7 | return ( 8 | 13 |
14 |
15 | question-mark 20 |
21 |

404 error

22 |

23 | We can't find that page 24 |

25 |

26 | Sorry, the page you are looking for doesn't exist or has been 27 | moved. 28 |

29 |
30 | 37 |
38 |
39 |
40 |
41 |
42 | ) 43 | } 44 | 45 | export default PageNotFound 46 | -------------------------------------------------------------------------------- /resources/pages/Placeholder.tsx: -------------------------------------------------------------------------------- 1 | import MainLayout from "@/layouts/MainLayout" 2 | import { useNavigate } from "react-router-dom" 3 | 4 | const PageNotFound: React.FC = () => { 5 | const navigate = useNavigate() 6 | 7 | return ( 8 | 13 |
14 |
15 | question-mark 20 |
21 |

22 | Under Construction 23 |

24 |

25 | We are working on this page 26 |

27 |

28 | Sorry, the page you are looking for doesn't exist or has been 29 | moved. 30 |

31 |
32 | 39 |
40 |
41 |
42 |
43 |
44 | ) 45 | } 46 | 47 | export default PageNotFound 48 | -------------------------------------------------------------------------------- /resources/pages/access/Register.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | import AuthLayout from "@/layouts/AuthLayout" 3 | import { buttonVariants } from "@/components/ui/button" 4 | import { UserRegistrationForm } from "./components/user-registration-form" 5 | import { Link } from "react-router-dom" 6 | export default function AuthenticationPage() { 7 | return ( 8 | 13 |
14 | 21 | Login 22 | 23 |
24 |
25 |
26 | 36 | 37 | 38 | Litestar Fullstack Application 39 |
40 |
41 |
42 |

43 | “This library has saved me countless hours of assessment 44 | work and helped me identify the best databases for us to start 45 | our migration journey with.” 46 |

47 |
A happy customer
48 |
49 |
50 |
51 |
52 |
53 |
54 |

55 | Create a Fullstack Account 56 |

57 |

58 | Enter your information below to create an account 59 |

60 |
61 | 62 |

63 | By clicking continue, you agree to our{" "} 64 | 68 | Terms of Service 69 | {" "} 70 | and{" "} 71 | 75 | Privacy Policy 76 | 77 | . 78 |

79 |
80 |
81 |
82 | 83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /resources/services/auth.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | import { API } from "@/types/api" 3 | const APP_URL = import.meta.env.APP_URL || "" 4 | export const registerUserService = async (data: any) => { 5 | try { 6 | const response = await axios.post( 7 | `${APP_URL}/api/access/signup`, 8 | data 9 | ) 10 | return response.data 11 | } catch (error) { 12 | throw error 13 | } 14 | } 15 | 16 | export const loginUserService = async (data: any) => { 17 | try { 18 | return await axios.post( 19 | `${APP_URL}/api/access/login`, 20 | data, 21 | { 22 | headers: { "Content-Type": "application/x-www-form-urlencoded" }, 23 | } 24 | ) 25 | } catch (error) { 26 | throw error 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /resources/services/profile.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | import { API } from "@/types/api" 3 | const APP_URL = import.meta.env.APP_URL || "" 4 | export const getUserProfileService = async (data: any) => { 5 | try { 6 | const response = await axios.post( 7 | `${APP_URL}/api/access/signup`, 8 | data 9 | ) 10 | return response.data 11 | } catch (error) { 12 | throw error 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /resources/types/nav.ts: -------------------------------------------------------------------------------- 1 | import { Icons } from "@/components/icons" 2 | 3 | export interface NavItem { 4 | title: string 5 | href?: string 6 | disabled?: boolean 7 | external?: boolean 8 | icon?: keyof typeof Icons 9 | label?: string 10 | } 11 | 12 | export interface NavItemWithChildren extends NavItem { 13 | items: NavItemWithChildren[] 14 | } 15 | 16 | export interface MainNavItem extends NavItem {} 17 | 18 | export interface SidebarNavItem extends NavItemWithChildren {} 19 | -------------------------------------------------------------------------------- /resources/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | # Path to sources 2 | sonar.sources=src/ 3 | sonar.exclusions=tools/, deploy/, bin/, artwork/, docs/ 4 | #sonar.inclusions= 5 | sonar.python.coverage.reportPaths=coverage.xml 6 | sonar.python.version=3.11, 3.12, 3.13 7 | sonar.tests=tests 8 | sonar.coverage.exclusions=\ 9 | **/__init__.py, \ 10 | src/app/db/migrations/versions/*.py, \ 11 | src/app/db/migrations/*.py, \ 12 | tests/*.py 13 | sonar.sourceEncoding=UTF-8 14 | 15 | # Exclusions for copy-paste detection 16 | sonar.cpd.exclusions=tools/*, deploy/*, bin/*, artwork/*, docs/*, resources/*, src/app/db/migrations/versions/env.py, resources/types/api.ts, resources/pages/access/components/user-login-form.tsx, resources/pages/access/components/user-registration-form.tsx 17 | sonar.projectName=Litestar Fullstack 18 | -------------------------------------------------------------------------------- /src/app/__about__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023-present Cody Fincher 2 | # 3 | # SPDX-License-Identifier: MIT 4 | __version__ = "0.2.0" 5 | -------------------------------------------------------------------------------- /src/app/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023-present Cody Fincher 2 | # 3 | # SPDX-License-Identifier: MIT 4 | import multiprocessing 5 | import platform 6 | 7 | if platform.system() == "Darwin": 8 | multiprocessing.set_start_method("fork", force=True) 9 | -------------------------------------------------------------------------------- /src/app/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import sys 5 | from pathlib import Path 6 | from typing import NoReturn 7 | 8 | 9 | def setup_environment() -> None: 10 | """Configure the environment variables and path.""" 11 | current_path = Path(__file__).parent.parent.resolve() 12 | sys.path.append(str(current_path)) 13 | from app.config import get_settings 14 | 15 | settings = get_settings() 16 | os.environ.setdefault("LITESTAR_APP", "app.asgi:create_app") 17 | os.environ.setdefault("LITESTAR_APP_NAME", settings.app.NAME) 18 | 19 | 20 | def run_cli() -> NoReturn: 21 | """Application Entrypoint. 22 | 23 | This function sets up the environment and runs the Litestar CLI. 24 | If there's an error loading the required libraries, it will exit with a status code of 1. 25 | 26 | Returns: 27 | NoReturn: This function does not return as it either runs the CLI or exits the program. 28 | 29 | Raises: 30 | SystemExit: If there's an error loading required libraries. 31 | """ 32 | setup_environment() 33 | 34 | try: 35 | from litestar.cli.main import litestar_group 36 | 37 | sys.exit(litestar_group()) 38 | except ImportError as exc: 39 | print( # noqa: T201 40 | "Could not load required libraries. ", 41 | "Please check your installation and make sure you activated any necessary virtual environment", 42 | ) 43 | print(exc) # noqa: T201 44 | sys.exit(1) 45 | 46 | 47 | if __name__ == "__main__": 48 | run_cli() 49 | -------------------------------------------------------------------------------- /src/app/asgi.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from litestar import Litestar 7 | 8 | 9 | def create_app() -> Litestar: 10 | """Create ASGI application.""" 11 | 12 | from litestar import Litestar 13 | 14 | from app.server.core import ApplicationCore 15 | 16 | return Litestar(plugins=[ApplicationCore()]) 17 | -------------------------------------------------------------------------------- /src/app/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/src/app/cli/__init__.py -------------------------------------------------------------------------------- /src/app/config/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from . import app as plugin_configs 4 | from . import constants 5 | from .base import BASE_DIR, DEFAULT_MODULE_NAME, Settings, get_settings 6 | 7 | __all__ = ( 8 | "BASE_DIR", 9 | "DEFAULT_MODULE_NAME", 10 | "Settings", 11 | "constants", 12 | "get_settings", 13 | "plugin_configs", 14 | ) 15 | -------------------------------------------------------------------------------- /src/app/config/constants.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | DB_SESSION_DEPENDENCY_KEY = "db_session" 4 | """The name of the key used for dependency injection of the database 5 | session.""" 6 | USER_DEPENDENCY_KEY = "current_user" 7 | """The name of the key used for dependency injection of the database 8 | session.""" 9 | DTO_INFO_KEY = "info" 10 | """The name of the key used for storing DTO information.""" 11 | DEFAULT_PAGINATION_SIZE = 20 12 | """Default page size to use.""" 13 | CACHE_EXPIRATION: int = 60 14 | """Default cache key expiration in seconds.""" 15 | DEFAULT_USER_ROLE = "Application Access" 16 | """The name of the default role assigned to all users.""" 17 | HEALTH_ENDPOINT = "/health" 18 | """The endpoint to use for the the service health check.""" 19 | SITE_INDEX = "/" 20 | """The site index URL.""" 21 | OPENAPI_SCHEMA = "/schema" 22 | """The URL path to use for the OpenAPI documentation.""" 23 | SUPERUSER_ACCESS_ROLE = "Superuser" 24 | """The name of the super user role.""" 25 | -------------------------------------------------------------------------------- /src/app/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/src/app/db/__init__.py -------------------------------------------------------------------------------- /src/app/db/fixtures/role.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "slug": "application-access", 4 | "name": "Application Access", 5 | "description": "Default role required for access. This role allows you to query and access the application." 6 | }, 7 | { 8 | "slug": "superuser", 9 | "name": "Superuser", 10 | "description": "Allows superuser access to the application." 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /src/app/db/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/src/app/db/migrations/__init__.py -------------------------------------------------------------------------------- /src/app/db/migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # Advanced Alchemy Alembic Asyncio Config 2 | 3 | [alembic] 4 | prepend_sys_path = src:. 5 | # path to migration scripts 6 | script_location = src/app/lib/db/migrations 7 | 8 | # template used to generate migration files 9 | file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s_%%(rev)s 10 | 11 | # This is not required to be set when running through `advanced_alchemy` 12 | # sqlalchemy.url = driver://user:pass@localhost/dbname 13 | 14 | # timezone to use when rendering the date 15 | # within the migration file as well as the filename. 16 | # string value is passed to dateutil.tz.gettz() 17 | # leave blank for localtime 18 | timezone = UTC 19 | 20 | # max length of characters to apply to the 21 | # "slug" field 22 | truncate_slug_length = 40 23 | 24 | # set to 'true' to run the environment during 25 | # the 'revision' command, regardless of autogenerate 26 | # revision_environment = false 27 | 28 | # set to 'true' to allow .pyc and .pyo files without 29 | # a source .py file to be detected as revisions in the 30 | # versions/ directory 31 | # sourceless = false 32 | 33 | # version location specification; this defaults 34 | # to alembic/versions. When using multiple version 35 | # directories, initial revisions must be specified with --version-path 36 | # version_locations = %(here)s/bar %(here)s/bat alembic/versions 37 | 38 | # version path separator; As mentioned above, this is the character used to split 39 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 40 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 41 | # Valid values for version_path_separator are: 42 | # 43 | # version_path_separator = : 44 | # version_path_separator = ; 45 | # version_path_separator = space 46 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 47 | 48 | # set to 'true' to search source files recursively 49 | # in each "version_locations" directory 50 | # new in Alembic version 1.10 51 | # recursive_version_locations = false 52 | 53 | # the output encoding used when revision files 54 | # are written from script.py.mako 55 | output_encoding = utf-8 56 | 57 | # [post_write_hooks] 58 | # This section defines scripts or Python functions that are run 59 | # on newly generated revision scripts. See the documentation for further 60 | # detail and examples 61 | 62 | # format using "black" - use the console_scripts runner, 63 | # against the "black" entrypoint 64 | # hooks = black 65 | # black.type = console_scripts 66 | # black.entrypoint = black 67 | # black.options = -l 120 REVISION_SCRIPT_FILENAME 68 | 69 | # lint with attempts to fix using "ruff" - use the exec runner, execute a binary 70 | # hooks = ruff 71 | # ruff.type = exec 72 | # ruff.executable = %(here)s/.venv/bin/ruff 73 | # ruff.options = --fix REVISION_SCRIPT_FILENAME 74 | -------------------------------------------------------------------------------- /src/app/db/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | """${message} 3 | 4 | Revision ID: ${up_revision} 5 | Revises: ${down_revision | comma,n} 6 | Create Date: ${create_date} 7 | 8 | """ 9 | from __future__ import annotations 10 | 11 | import warnings 12 | from typing import TYPE_CHECKING 13 | 14 | import sqlalchemy as sa 15 | from alembic import op 16 | from advanced_alchemy.types import EncryptedString, EncryptedText, GUID, ORA_JSONB, DateTimeUTC 17 | from sqlalchemy import Text # noqa: F401 18 | ${imports if imports else ""} 19 | if TYPE_CHECKING: 20 | from collections.abc import Sequence 21 | 22 | __all__ = ["downgrade", "upgrade", "schema_upgrades", "schema_downgrades", "data_upgrades", "data_downgrades"] 23 | 24 | sa.GUID = GUID 25 | sa.DateTimeUTC = DateTimeUTC 26 | sa.ORA_JSONB = ORA_JSONB 27 | sa.EncryptedString = EncryptedString 28 | sa.EncryptedText = EncryptedText 29 | 30 | # revision identifiers, used by Alembic. 31 | revision = ${repr(up_revision)} 32 | down_revision = ${repr(down_revision)} 33 | branch_labels = ${repr(branch_labels)} 34 | depends_on = ${repr(depends_on)} 35 | 36 | 37 | def upgrade() -> None: 38 | with warnings.catch_warnings(): 39 | warnings.filterwarnings("ignore", category=UserWarning) 40 | with op.get_context().autocommit_block(): 41 | schema_upgrades() 42 | data_upgrades() 43 | 44 | def downgrade() -> None: 45 | with warnings.catch_warnings(): 46 | warnings.filterwarnings("ignore", category=UserWarning) 47 | with op.get_context().autocommit_block(): 48 | data_downgrades() 49 | schema_downgrades() 50 | 51 | def schema_upgrades() -> None: 52 | """schema upgrade migrations go here.""" 53 | ${upgrades if upgrades else "pass"} 54 | 55 | def schema_downgrades() -> None: 56 | """schema downgrade migrations go here.""" 57 | ${downgrades if downgrades else "pass"} 58 | 59 | def data_upgrades() -> None: 60 | """Add any optional data upgrade migrations here!""" 61 | 62 | def data_downgrades() -> None: 63 | """Add any optional data downgrade migrations here!""" 64 | -------------------------------------------------------------------------------- /src/app/db/migrations/versions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/src/app/db/migrations/versions/__init__.py -------------------------------------------------------------------------------- /src/app/db/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .oauth_account import UserOauthAccount 2 | from .role import Role 3 | from .tag import Tag 4 | from .team import Team 5 | from .team_invitation import TeamInvitation 6 | from .team_member import TeamMember 7 | from .team_roles import TeamRoles 8 | from .team_tag import team_tag 9 | from .user import User 10 | from .user_role import UserRole 11 | 12 | __all__ = ( 13 | "Role", 14 | "Tag", 15 | "Team", 16 | "TeamInvitation", 17 | "TeamMember", 18 | "TeamRoles", 19 | "User", 20 | "UserOauthAccount", 21 | "UserRole", 22 | "team_tag", 23 | ) 24 | -------------------------------------------------------------------------------- /src/app/db/models/oauth_account.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | from uuid import UUID # noqa: TC003 5 | 6 | from advanced_alchemy.base import UUIDAuditBase 7 | from sqlalchemy import ForeignKey, Integer, String 8 | from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy 9 | from sqlalchemy.orm import Mapped, mapped_column, relationship 10 | 11 | if TYPE_CHECKING: 12 | from .user import User 13 | 14 | 15 | class UserOauthAccount(UUIDAuditBase): 16 | """User Oauth Account""" 17 | 18 | __tablename__ = "user_account_oauth" 19 | __table_args__ = {"comment": "Registered OAUTH2 Accounts for Users"} 20 | __pii_columns__ = {"oauth_name", "account_email", "account_id"} 21 | 22 | user_id: Mapped[UUID] = mapped_column( 23 | ForeignKey("user_account.id", ondelete="cascade"), 24 | nullable=False, 25 | ) 26 | oauth_name: Mapped[str] = mapped_column(String(length=100), index=True, nullable=False) 27 | access_token: Mapped[str] = mapped_column(String(length=1024), nullable=False) 28 | expires_at: Mapped[int | None] = mapped_column(Integer, nullable=True) 29 | refresh_token: Mapped[str | None] = mapped_column(String(length=1024), nullable=True) 30 | account_id: Mapped[str] = mapped_column(String(length=320), index=True, nullable=False) 31 | account_email: Mapped[str] = mapped_column(String(length=320), nullable=False) 32 | 33 | # ----------- 34 | # ORM Relationships 35 | # ------------ 36 | user_name: AssociationProxy[str] = association_proxy("user", "name") 37 | user_email: AssociationProxy[str] = association_proxy("user", "email") 38 | user: Mapped[User] = relationship( 39 | back_populates="oauth_accounts", 40 | viewonly=True, 41 | innerjoin=True, 42 | lazy="joined", 43 | ) 44 | -------------------------------------------------------------------------------- /src/app/db/models/role.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from advanced_alchemy.base import UUIDAuditBase 6 | from advanced_alchemy.mixins import SlugKey 7 | from sqlalchemy.orm import Mapped, mapped_column, relationship 8 | 9 | if TYPE_CHECKING: 10 | from .user_role import UserRole 11 | 12 | 13 | class Role(UUIDAuditBase, SlugKey): 14 | """Role.""" 15 | 16 | __tablename__ = "role" 17 | 18 | name: Mapped[str] = mapped_column(unique=True) 19 | description: Mapped[str | None] 20 | # ----------- 21 | # ORM Relationships 22 | # ------------ 23 | users: Mapped[list[UserRole]] = relationship( 24 | back_populates="role", 25 | cascade="all, delete", 26 | lazy="noload", 27 | viewonly=True, 28 | ) 29 | -------------------------------------------------------------------------------- /src/app/db/models/tag.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from advanced_alchemy.base import UUIDAuditBase 6 | from advanced_alchemy.mixins import SlugKey, UniqueMixin 7 | from advanced_alchemy.utils.text import slugify 8 | from sqlalchemy import ( 9 | ColumnElement, 10 | String, 11 | Table, 12 | ) 13 | from sqlalchemy.orm import Mapped, mapped_column, relationship 14 | 15 | if TYPE_CHECKING: 16 | from collections.abc import Hashable 17 | 18 | from .team import Team 19 | 20 | 21 | class Tag(UUIDAuditBase, SlugKey, UniqueMixin): 22 | """Tag.""" 23 | 24 | __tablename__ = "tag" 25 | name: Mapped[str] = mapped_column(index=False) 26 | description: Mapped[str | None] = mapped_column(String(length=255), index=False, nullable=True) 27 | 28 | # ----------- 29 | # ORM Relationships 30 | # ------------ 31 | teams: Mapped[list[Team]] = relationship( 32 | secondary=lambda: _team_tag(), 33 | back_populates="tags", 34 | ) 35 | 36 | @classmethod 37 | def unique_hash(cls, name: str, slug: str | None = None) -> Hashable: # noqa: ARG003 38 | return slugify(name) 39 | 40 | @classmethod 41 | def unique_filter( 42 | cls, 43 | name: str, 44 | slug: str | None = None, # noqa: ARG003 45 | ) -> ColumnElement[bool]: 46 | return cls.slug == slugify(name) 47 | 48 | 49 | def _team_tag() -> Table: 50 | from .team_tag import team_tag 51 | 52 | return team_tag 53 | -------------------------------------------------------------------------------- /src/app/db/models/team.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from advanced_alchemy.base import UUIDAuditBase 6 | from advanced_alchemy.mixins import SlugKey 7 | from sqlalchemy import String 8 | from sqlalchemy.orm import Mapped, mapped_column, relationship 9 | 10 | from .team_tag import team_tag 11 | 12 | if TYPE_CHECKING: 13 | from .tag import Tag 14 | from .team_invitation import TeamInvitation 15 | from .team_member import TeamMember 16 | 17 | 18 | class Team(UUIDAuditBase, SlugKey): 19 | """A group of users with common permissions. 20 | Users can create and invite users to a team. 21 | """ 22 | 23 | __tablename__ = "team" 24 | __pii_columns__ = {"name", "description"} 25 | name: Mapped[str] = mapped_column(nullable=False, index=True) 26 | description: Mapped[str | None] = mapped_column(String(length=500), nullable=True, default=None) 27 | is_active: Mapped[bool] = mapped_column(default=True, nullable=False) 28 | # ----------- 29 | # ORM Relationships 30 | # ------------ 31 | members: Mapped[list[TeamMember]] = relationship( 32 | back_populates="team", 33 | cascade="all, delete", 34 | passive_deletes=True, 35 | lazy="selectin", 36 | ) 37 | invitations: Mapped[list[TeamInvitation]] = relationship( 38 | back_populates="team", 39 | cascade="all, delete", 40 | ) 41 | pending_invitations: Mapped[list[TeamInvitation]] = relationship( 42 | primaryjoin="and_(TeamInvitation.team_id==Team.id, TeamInvitation.is_accepted == False)", 43 | viewonly=True, 44 | ) 45 | tags: Mapped[list[Tag]] = relationship( 46 | secondary=lambda: team_tag, 47 | back_populates="teams", 48 | cascade="all, delete", 49 | passive_deletes=True, 50 | ) 51 | -------------------------------------------------------------------------------- /src/app/db/models/team_invitation.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | from uuid import UUID # noqa: TC003 5 | 6 | from advanced_alchemy.base import UUIDAuditBase 7 | from sqlalchemy import ForeignKey, String 8 | from sqlalchemy.orm import Mapped, mapped_column, relationship 9 | 10 | from app.db.models.team_roles import TeamRoles 11 | 12 | if TYPE_CHECKING: 13 | from .team import Team 14 | from .user import User 15 | 16 | 17 | class TeamInvitation(UUIDAuditBase): 18 | """Team Invite.""" 19 | 20 | __tablename__ = "team_invitation" 21 | team_id: Mapped[UUID] = mapped_column(ForeignKey("team.id", ondelete="cascade")) 22 | email: Mapped[str] = mapped_column(index=True) 23 | role: Mapped[TeamRoles] = mapped_column(String(length=50), default=TeamRoles.MEMBER) 24 | is_accepted: Mapped[bool] = mapped_column(default=False) 25 | invited_by_id: Mapped[UUID | None] = mapped_column(ForeignKey("user_account.id", ondelete="set null")) 26 | invited_by_email: Mapped[str] 27 | # ----------- 28 | # ORM Relationships 29 | # ------------ 30 | team: Mapped[Team] = relationship(foreign_keys="TeamInvitation.team_id", lazy="noload") 31 | invited_by: Mapped[User] = relationship(foreign_keys="TeamInvitation.invited_by_id", lazy="noload", uselist=False) 32 | -------------------------------------------------------------------------------- /src/app/db/models/team_member.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | from uuid import UUID # noqa: TC003 5 | 6 | from advanced_alchemy.base import UUIDAuditBase 7 | from sqlalchemy import ForeignKey, String, UniqueConstraint 8 | from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy 9 | from sqlalchemy.orm import Mapped, mapped_column, relationship 10 | 11 | from .team_roles import TeamRoles 12 | 13 | if TYPE_CHECKING: 14 | from .team import Team 15 | from .user import User 16 | 17 | 18 | class TeamMember(UUIDAuditBase): 19 | """Team Membership.""" 20 | 21 | __tablename__ = "team_member" 22 | __table_args__ = (UniqueConstraint("user_id", "team_id"),) 23 | user_id: Mapped[UUID] = mapped_column(ForeignKey("user_account.id", ondelete="cascade"), nullable=False) 24 | team_id: Mapped[UUID] = mapped_column(ForeignKey("team.id", ondelete="cascade"), nullable=False) 25 | role: Mapped[TeamRoles] = mapped_column( 26 | String(length=50), 27 | default=TeamRoles.MEMBER, 28 | nullable=False, 29 | index=True, 30 | ) 31 | is_owner: Mapped[bool] = mapped_column(default=False, nullable=False) 32 | 33 | # ----------- 34 | # ORM Relationships 35 | # ------------ 36 | user: Mapped[User] = relationship( 37 | back_populates="teams", 38 | foreign_keys="TeamMember.user_id", 39 | innerjoin=True, 40 | uselist=False, 41 | lazy="joined", 42 | ) 43 | name: AssociationProxy[str] = association_proxy("user", "name") 44 | email: AssociationProxy[str] = association_proxy("user", "email") 45 | team: Mapped[Team] = relationship( 46 | back_populates="members", 47 | foreign_keys="TeamMember.team_id", 48 | innerjoin=True, 49 | uselist=False, 50 | lazy="joined", 51 | ) 52 | team_name: AssociationProxy[str] = association_proxy("team", "name") 53 | -------------------------------------------------------------------------------- /src/app/db/models/team_roles.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import Enum 4 | 5 | 6 | class TeamRoles(str, Enum): 7 | """Valid Values for Team Roles.""" 8 | 9 | ADMIN = "ADMIN" 10 | MEMBER = "MEMBER" 11 | -------------------------------------------------------------------------------- /src/app/db/models/team_tag.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from advanced_alchemy.base import orm_registry 4 | from sqlalchemy import Column, ForeignKey, Table 5 | 6 | team_tag = Table( 7 | "team_tag", 8 | orm_registry.metadata, 9 | Column("team_id", ForeignKey("team.id", ondelete="CASCADE"), primary_key=True), 10 | Column("tag_id", ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True), 11 | ) 12 | -------------------------------------------------------------------------------- /src/app/db/models/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import date, datetime 4 | from typing import TYPE_CHECKING 5 | 6 | from advanced_alchemy.base import UUIDAuditBase 7 | from sqlalchemy import String 8 | from sqlalchemy.ext.hybrid import hybrid_property 9 | from sqlalchemy.orm import Mapped, mapped_column, relationship 10 | 11 | if TYPE_CHECKING: 12 | from .oauth_account import UserOauthAccount 13 | from .team_member import TeamMember 14 | from .user_role import UserRole 15 | 16 | 17 | class User(UUIDAuditBase): 18 | __tablename__ = "user_account" 19 | __table_args__ = {"comment": "User accounts for application access"} 20 | __pii_columns__ = {"name", "email", "avatar_url"} 21 | 22 | email: Mapped[str] = mapped_column(unique=True, index=True, nullable=False) 23 | name: Mapped[str | None] = mapped_column(nullable=True, default=None) 24 | hashed_password: Mapped[str | None] = mapped_column(String(length=255), nullable=True, default=None) 25 | avatar_url: Mapped[str | None] = mapped_column(String(length=500), nullable=True, default=None) 26 | is_active: Mapped[bool] = mapped_column(default=True, nullable=False) 27 | is_superuser: Mapped[bool] = mapped_column(default=False, nullable=False) 28 | is_verified: Mapped[bool] = mapped_column(default=False, nullable=False) 29 | verified_at: Mapped[date] = mapped_column(nullable=True, default=None) 30 | joined_at: Mapped[date] = mapped_column(default=datetime.now) 31 | login_count: Mapped[int] = mapped_column(default=0) 32 | # ----------- 33 | # ORM Relationships 34 | # ------------ 35 | 36 | roles: Mapped[list[UserRole]] = relationship( 37 | back_populates="user", 38 | lazy="selectin", 39 | uselist=True, 40 | cascade="all, delete", 41 | ) 42 | teams: Mapped[list[TeamMember]] = relationship( 43 | back_populates="user", 44 | lazy="selectin", 45 | uselist=True, 46 | cascade="all, delete", 47 | viewonly=True, 48 | ) 49 | oauth_accounts: Mapped[list[UserOauthAccount]] = relationship( 50 | back_populates="user", 51 | lazy="noload", 52 | cascade="all, delete", 53 | uselist=True, 54 | ) 55 | 56 | @hybrid_property 57 | def has_password(self) -> bool: 58 | return self.hashed_password is not None 59 | -------------------------------------------------------------------------------- /src/app/db/models/user_role.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import UTC, datetime 4 | from typing import TYPE_CHECKING 5 | from uuid import UUID # noqa: TC003 6 | 7 | from advanced_alchemy.base import UUIDAuditBase 8 | from sqlalchemy import ForeignKey 9 | from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy 10 | from sqlalchemy.orm import Mapped, mapped_column, relationship 11 | 12 | if TYPE_CHECKING: 13 | from .role import Role 14 | from .user import User 15 | 16 | 17 | class UserRole(UUIDAuditBase): 18 | """User Role.""" 19 | 20 | __tablename__ = "user_account_role" 21 | __table_args__ = {"comment": "Links a user to a specific role."} 22 | user_id: Mapped[UUID] = mapped_column(ForeignKey("user_account.id", ondelete="cascade"), nullable=False) 23 | role_id: Mapped[UUID] = mapped_column(ForeignKey("role.id", ondelete="cascade"), nullable=False) 24 | assigned_at: Mapped[datetime] = mapped_column(default=datetime.now(UTC)) 25 | 26 | # ----------- 27 | # ORM Relationships 28 | # ------------ 29 | user: Mapped[User] = relationship(back_populates="roles", innerjoin=True, uselist=False, lazy="joined") 30 | user_name: AssociationProxy[str] = association_proxy("user", "name") 31 | user_email: AssociationProxy[str] = association_proxy("user", "email") 32 | role: Mapped[Role] = relationship(back_populates="users", innerjoin=True, uselist=False, lazy="joined") 33 | role_name: AssociationProxy[str] = association_proxy("role", "name") 34 | role_slug: AssociationProxy[str] = association_proxy("role", "slug") 35 | -------------------------------------------------------------------------------- /src/app/domain/__init__.py: -------------------------------------------------------------------------------- 1 | """Application Modules.""" 2 | 3 | from __future__ import annotations 4 | -------------------------------------------------------------------------------- /src/app/domain/accounts/__init__.py: -------------------------------------------------------------------------------- 1 | """User Account domain logic.""" 2 | 3 | from app.domain.accounts import controllers, deps, guards, schemas, services, signals, urls 4 | 5 | __all__ = ("controllers", "deps", "guards", "schemas", "services", "signals", "urls") 6 | -------------------------------------------------------------------------------- /src/app/domain/accounts/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | from .access import AccessController 2 | from .roles import RoleController 3 | from .user_role import UserRoleController 4 | from .users import UserController 5 | 6 | __all__ = ("AccessController", "RoleController", "UserController", "UserRoleController") 7 | -------------------------------------------------------------------------------- /src/app/domain/accounts/controllers/access.py: -------------------------------------------------------------------------------- 1 | """User Account Controllers.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, Annotated 6 | 7 | from advanced_alchemy.utils.text import slugify 8 | from litestar import Controller, Request, Response, get, post 9 | from litestar.di import Provide 10 | from litestar.enums import RequestEncodingType 11 | from litestar.params import Body 12 | 13 | from app.domain.accounts import urls 14 | from app.domain.accounts.deps import provide_users_service 15 | from app.domain.accounts.guards import auth, requires_active_user 16 | from app.domain.accounts.schemas import AccountLogin, AccountRegister, User 17 | from app.domain.accounts.services import RoleService 18 | from app.lib.deps import create_service_provider 19 | 20 | if TYPE_CHECKING: 21 | from litestar.security.jwt import OAuth2Login 22 | 23 | from app.db import models as m 24 | from app.domain.accounts.services import UserService 25 | 26 | 27 | class AccessController(Controller): 28 | """User login and registration.""" 29 | 30 | tags = ["Access"] 31 | dependencies = { 32 | "users_service": Provide(provide_users_service), 33 | "roles_service": Provide(create_service_provider(RoleService)), 34 | } 35 | 36 | @post(operation_id="AccountLogin", path=urls.ACCOUNT_LOGIN, exclude_from_auth=True) 37 | async def login( 38 | self, 39 | users_service: UserService, 40 | data: Annotated[AccountLogin, Body(title="OAuth2 Login", media_type=RequestEncodingType.URL_ENCODED)], 41 | ) -> Response[OAuth2Login]: 42 | """Authenticate a user.""" 43 | user = await users_service.authenticate(data.username, data.password) 44 | return auth.login(user.email) 45 | 46 | @post(operation_id="AccountLogout", path=urls.ACCOUNT_LOGOUT, exclude_from_auth=True) 47 | async def logout(self, request: Request) -> Response: 48 | """Account Logout""" 49 | request.cookies.pop(auth.key, None) 50 | request.clear_session() 51 | 52 | response = Response( 53 | {"message": "OK"}, 54 | status_code=200, 55 | ) 56 | response.delete_cookie(auth.key) 57 | 58 | return response 59 | 60 | @post(operation_id="AccountRegister", path=urls.ACCOUNT_REGISTER) 61 | async def signup( 62 | self, 63 | request: Request, 64 | users_service: UserService, 65 | roles_service: RoleService, 66 | data: AccountRegister, 67 | ) -> User: 68 | """User Signup.""" 69 | user_data = data.to_dict() 70 | role_obj = await roles_service.get_one_or_none(slug=slugify(users_service.default_role)) 71 | if role_obj is not None: 72 | user_data.update({"role_id": role_obj.id}) 73 | user = await users_service.create(user_data) 74 | request.app.emit(event_id="user_created", user_id=user.id) 75 | return users_service.to_schema(user, schema_type=User) 76 | 77 | @get(operation_id="AccountProfile", path=urls.ACCOUNT_PROFILE, guards=[requires_active_user]) 78 | async def profile(self, current_user: m.User, users_service: UserService) -> User: 79 | """User Profile.""" 80 | return users_service.to_schema(current_user, schema_type=User) 81 | -------------------------------------------------------------------------------- /src/app/domain/accounts/controllers/roles.py: -------------------------------------------------------------------------------- 1 | """Role Routes.""" 2 | 3 | from __future__ import annotations 4 | 5 | from litestar import Controller 6 | 7 | from app.domain.accounts.guards import requires_superuser 8 | 9 | 10 | class RoleController(Controller): 11 | """Handles the adding and removing of new Roles.""" 12 | 13 | tags = ["Roles"] 14 | guards = [requires_superuser] 15 | -------------------------------------------------------------------------------- /src/app/domain/accounts/controllers/user_role.py: -------------------------------------------------------------------------------- 1 | """User Routes.""" 2 | 3 | from __future__ import annotations 4 | 5 | from litestar import Controller, post 6 | from litestar.di import Provide 7 | from litestar.params import Parameter 8 | from litestar.repository.exceptions import ConflictError 9 | 10 | from app.domain.accounts import deps, schemas, urls 11 | from app.domain.accounts.guards import requires_superuser 12 | from app.domain.accounts.services import RoleService, UserRoleService, UserService 13 | from app.lib.deps import create_service_provider 14 | from app.lib.schema import Message 15 | 16 | 17 | class UserRoleController(Controller): 18 | """Handles the adding and removing of User Role records.""" 19 | 20 | tags = ["User Account Roles"] 21 | guards = [requires_superuser] 22 | dependencies = { 23 | "user_roles_service": Provide(create_service_provider(UserRoleService)), 24 | "roles_service": Provide(create_service_provider(RoleService)), 25 | "users_service": Provide(deps.provide_users_service), 26 | } 27 | 28 | @post(operation_id="AssignUserRole", path=urls.ACCOUNT_ASSIGN_ROLE) 29 | async def assign_role( 30 | self, 31 | roles_service: RoleService, 32 | users_service: UserService, 33 | user_roles_service: UserRoleService, 34 | data: schemas.UserRoleAdd, 35 | role_slug: str = Parameter(title="Role Slug", description="The role to grant."), 36 | ) -> Message: 37 | """Create a new migration role.""" 38 | role_id = (await roles_service.get_one(slug=role_slug)).id 39 | user_obj = await users_service.get_one(email=data.user_name) 40 | obj, created = await user_roles_service.get_or_upsert(role_id=role_id, user_id=user_obj.id) 41 | if created: 42 | return Message(message=f"Successfully assigned the '{obj.role_slug}' role to {obj.user_email}.") 43 | return Message(message=f"User {obj.user_email} already has the '{obj.role_slug}' role.") 44 | 45 | @post(operation_id="RevokeUserRole", path=urls.ACCOUNT_REVOKE_ROLE) 46 | async def revoke_role( 47 | self, 48 | users_service: UserService, 49 | user_roles_service: UserRoleService, 50 | data: schemas.UserRoleRevoke, 51 | role_slug: str = Parameter(title="Role Slug", description="The role to revoke."), 52 | ) -> Message: 53 | """Delete a role from the system.""" 54 | user_obj = await users_service.get_one(email=data.user_name) 55 | removed_role: bool = False 56 | for user_role in user_obj.roles: 57 | if user_role.role_slug == role_slug: 58 | _ = await user_roles_service.delete(user_role.id) 59 | removed_role = True 60 | if not removed_role: 61 | msg = "User did not have role assigned." 62 | raise ConflictError(msg) 63 | return Message(message=f"Removed the '{role_slug}' role from User {user_obj.email}.") 64 | -------------------------------------------------------------------------------- /src/app/domain/accounts/controllers/users.py: -------------------------------------------------------------------------------- 1 | """User Account Controllers.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, Annotated 6 | from uuid import UUID 7 | 8 | from litestar import Controller, delete, get, patch, post 9 | from litestar.di import Provide 10 | from litestar.params import Dependency, Parameter 11 | 12 | from app.domain.accounts import urls 13 | from app.domain.accounts.deps import provide_users_service 14 | from app.domain.accounts.guards import requires_superuser 15 | from app.domain.accounts.schemas import User, UserCreate, UserUpdate 16 | from app.lib.deps import create_filter_dependencies 17 | 18 | if TYPE_CHECKING: 19 | from advanced_alchemy.filters import FilterTypes 20 | from advanced_alchemy.service import OffsetPagination 21 | 22 | from app.domain.accounts.services import UserService 23 | 24 | 25 | class UserController(Controller): 26 | """User Account Controller.""" 27 | 28 | tags = ["User Accounts"] 29 | guards = [requires_superuser] 30 | dependencies = { 31 | "users_service": Provide(provide_users_service), 32 | } | create_filter_dependencies( 33 | { 34 | "id_filter": UUID, 35 | "search": "name,email", 36 | "pagination_type": "limit_offset", 37 | "pagination_size": 20, 38 | "created_at": True, 39 | "updated_at": True, 40 | "sort_field": "name", 41 | "sort_order": "asc", 42 | }, 43 | ) 44 | 45 | @get(operation_id="ListUsers", path=urls.ACCOUNT_LIST, cache=60) 46 | async def list_users( 47 | self, 48 | users_service: UserService, 49 | filters: Annotated[list[FilterTypes], Dependency(skip_validation=True)], 50 | ) -> OffsetPagination[User]: 51 | """List users.""" 52 | results, total = await users_service.list_and_count(*filters) 53 | return users_service.to_schema(data=results, total=total, schema_type=User, filters=filters) 54 | 55 | @get(operation_id="GetUser", path=urls.ACCOUNT_DETAIL) 56 | async def get_user( 57 | self, 58 | users_service: UserService, 59 | user_id: Annotated[UUID, Parameter(title="User ID", description="The user to retrieve.")], 60 | ) -> User: 61 | """Get a user.""" 62 | db_obj = await users_service.get(user_id) 63 | return users_service.to_schema(db_obj, schema_type=User) 64 | 65 | @post(operation_id="CreateUser", path=urls.ACCOUNT_CREATE) 66 | async def create_user(self, users_service: UserService, data: UserCreate) -> User: 67 | """Create a new user.""" 68 | db_obj = await users_service.create(data.to_dict()) 69 | return users_service.to_schema(db_obj, schema_type=User) 70 | 71 | @patch(operation_id="UpdateUser", path=urls.ACCOUNT_UPDATE) 72 | async def update_user( 73 | self, 74 | data: UserUpdate, 75 | users_service: UserService, 76 | user_id: UUID = Parameter(title="User ID", description="The user to update."), 77 | ) -> User: 78 | """Create a new user.""" 79 | db_obj = await users_service.update(item_id=user_id, data=data.to_dict()) 80 | return users_service.to_schema(db_obj, schema_type=User) 81 | 82 | @delete(operation_id="DeleteUser", path=urls.ACCOUNT_DELETE) 83 | async def delete_user( 84 | self, 85 | users_service: UserService, 86 | user_id: Annotated[UUID, Parameter(title="User ID", description="The user to delete.")], 87 | ) -> None: 88 | """Delete a user from the system.""" 89 | _ = await users_service.delete(user_id) 90 | -------------------------------------------------------------------------------- /src/app/domain/accounts/deps.py: -------------------------------------------------------------------------------- 1 | """User Account Controllers.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from sqlalchemy.orm import joinedload, load_only, selectinload 8 | 9 | from app.db import models as m 10 | from app.domain.accounts.services import UserService 11 | from app.lib.deps import create_service_provider 12 | 13 | if TYPE_CHECKING: 14 | from litestar import Request 15 | 16 | # create a hard reference to this since it's used oven 17 | provide_users_service = create_service_provider( 18 | UserService, 19 | load=[ 20 | selectinload(m.User.roles).options(joinedload(m.UserRole.role, innerjoin=True)), 21 | selectinload(m.User.oauth_accounts), 22 | selectinload(m.User.teams).options( 23 | joinedload(m.TeamMember.team, innerjoin=True).options(load_only(m.Team.name)), 24 | ), 25 | ], 26 | error_messages={"duplicate_key": "This user already exists.", "integrity": "User operation failed."}, 27 | ) 28 | 29 | 30 | async def provide_user(request: Request[m.User, Any, Any]) -> m.User: 31 | """Get the user from the request. 32 | 33 | Args: 34 | request: current Request. 35 | 36 | Returns: 37 | User 38 | """ 39 | return request.user 40 | -------------------------------------------------------------------------------- /src/app/domain/accounts/guards.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from litestar.exceptions import PermissionDeniedException 6 | from litestar.security.jwt import OAuth2PasswordBearerAuth 7 | 8 | from app.config import constants 9 | from app.config.app import alchemy 10 | from app.config.base import get_settings 11 | from app.db import models as m 12 | from app.domain.accounts import urls 13 | from app.domain.accounts.deps import provide_users_service 14 | 15 | if TYPE_CHECKING: 16 | from litestar.connection import ASGIConnection 17 | from litestar.handlers.base import BaseRouteHandler 18 | from litestar.security.jwt import Token 19 | 20 | 21 | __all__ = ("auth", "current_user_from_token", "requires_active_user", "requires_superuser", "requires_verified_user") 22 | 23 | 24 | settings = get_settings() 25 | 26 | 27 | def requires_active_user(connection: ASGIConnection, _: BaseRouteHandler) -> None: 28 | """Request requires active user. 29 | 30 | Verifies the request user is active. 31 | 32 | Args: 33 | connection (ASGIConnection): HTTP Request 34 | _ (BaseRouteHandler): Route handler 35 | 36 | Raises: 37 | PermissionDeniedException: Permission denied exception 38 | """ 39 | if connection.user.is_active: 40 | return 41 | msg = "Inactive account" 42 | raise PermissionDeniedException(msg) 43 | 44 | 45 | def requires_superuser(connection: ASGIConnection[m.User, Any, Any, Any], _: BaseRouteHandler) -> None: 46 | """Request requires active superuser. 47 | 48 | Args: 49 | connection (ASGIConnection): HTTP Request 50 | _ (BaseRouteHandler): Route handler 51 | 52 | Raises: 53 | PermissionDeniedException: Permission denied exception 54 | 55 | Returns: 56 | None: Returns None when successful 57 | """ 58 | if connection.user.is_superuser: 59 | return 60 | raise PermissionDeniedException(detail="Insufficient privileges") 61 | 62 | 63 | def requires_verified_user(connection: ASGIConnection[m.User, Any, Any, Any], _: BaseRouteHandler) -> None: 64 | """Verify the connection user is a superuser. 65 | 66 | Args: 67 | connection (ASGIConnection): Request/Connection object. 68 | _ (BaseRouteHandler): Route handler. 69 | 70 | Raises: 71 | PermissionDeniedException: Not authorized 72 | 73 | Returns: 74 | None: Returns None when successful 75 | """ 76 | if connection.user.is_verified: 77 | return 78 | raise PermissionDeniedException(detail="User account is not verified.") 79 | 80 | 81 | async def current_user_from_token(token: Token, connection: ASGIConnection[Any, Any, Any, Any]) -> m.User | None: 82 | """Lookup current user from local JWT token. 83 | 84 | Fetches the user information from the database 85 | 86 | 87 | Args: 88 | token (str): JWT Token Object 89 | connection (ASGIConnection[Any, Any, Any, Any]): ASGI connection. 90 | 91 | 92 | Returns: 93 | User: User record mapped to the JWT identifier 94 | """ 95 | service = await anext(provide_users_service(alchemy.provide_session(connection.app.state, connection.scope))) 96 | user = await service.get_one_or_none(email=token.sub) 97 | return user if user and user.is_active else None 98 | 99 | 100 | auth = OAuth2PasswordBearerAuth[m.User]( 101 | retrieve_user_handler=current_user_from_token, 102 | token_secret=settings.app.SECRET_KEY, 103 | token_url=urls.ACCOUNT_LOGIN, 104 | exclude=[ 105 | constants.HEALTH_ENDPOINT, 106 | urls.ACCOUNT_LOGIN, 107 | urls.ACCOUNT_REGISTER, 108 | "^/schema", 109 | "^/public/", 110 | "^/saq/static/", 111 | ], 112 | ) 113 | -------------------------------------------------------------------------------- /src/app/domain/accounts/schemas.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime # noqa: TC003 4 | from uuid import UUID # noqa: TC003 5 | 6 | import msgspec 7 | 8 | from app.db.models.team_roles import TeamRoles 9 | from app.lib.schema import CamelizedBaseStruct 10 | 11 | __all__ = ( 12 | "AccountLogin", 13 | "AccountRegister", 14 | "User", 15 | "UserCreate", 16 | "UserRole", 17 | "UserRoleAdd", 18 | "UserRoleRevoke", 19 | "UserTeam", 20 | "UserUpdate", 21 | ) 22 | 23 | 24 | class UserTeam(CamelizedBaseStruct): 25 | """Holds team details for a user. 26 | 27 | This is nested in the User Model for 'team' 28 | """ 29 | 30 | team_id: UUID 31 | team_name: str 32 | is_owner: bool = False 33 | role: TeamRoles = TeamRoles.MEMBER 34 | 35 | 36 | class UserRole(CamelizedBaseStruct): 37 | """Holds role details for a user. 38 | 39 | This is nested in the User Model for 'roles' 40 | """ 41 | 42 | role_id: UUID 43 | role_slug: str 44 | role_name: str 45 | assigned_at: datetime 46 | 47 | 48 | class OauthAccount(CamelizedBaseStruct): 49 | """Holds linked Oauth details for a user.""" 50 | 51 | id: UUID 52 | oauth_name: str 53 | access_token: str 54 | account_id: str 55 | account_email: str 56 | expires_at: int | None = None 57 | refresh_token: str | None = None 58 | 59 | 60 | class User(CamelizedBaseStruct): 61 | """User properties to use for a response.""" 62 | 63 | id: UUID 64 | email: str 65 | name: str | None = None 66 | is_superuser: bool = False 67 | is_active: bool = False 68 | is_verified: bool = False 69 | has_password: bool = False 70 | teams: list[UserTeam] = [] 71 | roles: list[UserRole] = [] 72 | oauth_accounts: list[OauthAccount] = [] 73 | 74 | 75 | class UserCreate(CamelizedBaseStruct): 76 | email: str 77 | password: str 78 | name: str | None = None 79 | is_superuser: bool = False 80 | is_active: bool = True 81 | is_verified: bool = False 82 | 83 | 84 | class UserUpdate(CamelizedBaseStruct, omit_defaults=True): 85 | email: str | None | msgspec.UnsetType = msgspec.UNSET 86 | password: str | None | msgspec.UnsetType = msgspec.UNSET 87 | name: str | None | msgspec.UnsetType = msgspec.UNSET 88 | is_superuser: bool | None | msgspec.UnsetType = msgspec.UNSET 89 | is_active: bool | None | msgspec.UnsetType = msgspec.UNSET 90 | is_verified: bool | None | msgspec.UnsetType = msgspec.UNSET 91 | 92 | 93 | class AccountLogin(CamelizedBaseStruct): 94 | username: str 95 | password: str 96 | 97 | 98 | class AccountRegister(CamelizedBaseStruct): 99 | email: str 100 | password: str 101 | name: str | None = None 102 | 103 | 104 | class UserRoleAdd(CamelizedBaseStruct): 105 | """User role add .""" 106 | 107 | user_name: str 108 | 109 | 110 | class UserRoleRevoke(CamelizedBaseStruct): 111 | """User role revoke .""" 112 | 113 | user_name: str 114 | -------------------------------------------------------------------------------- /src/app/domain/accounts/signals.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import structlog 6 | from litestar.events import listener 7 | 8 | from app.config.app import alchemy 9 | 10 | from .deps import provide_users_service 11 | 12 | if TYPE_CHECKING: 13 | from uuid import UUID 14 | 15 | logger = structlog.get_logger() 16 | 17 | 18 | @listener("user_created") 19 | async def user_created_event_handler( 20 | user_id: UUID, 21 | ) -> None: 22 | """Executes when a new user is created. 23 | 24 | Args: 25 | user_id: The primary key of the user that was created. 26 | """ 27 | await logger.ainfo("Running post signup flow.") 28 | async with alchemy.get_session() as db_session: 29 | service = await anext(provide_users_service(db_session)) 30 | obj = await service.get_one_or_none(id=user_id) 31 | if obj is None: 32 | await logger.aerror("Could not locate the specified user", id=user_id) 33 | else: 34 | await logger.ainfo("Found user", **obj.to_dict(exclude={"hashed_password"})) 35 | -------------------------------------------------------------------------------- /src/app/domain/accounts/urls.py: -------------------------------------------------------------------------------- 1 | ACCOUNT_LOGIN = "/api/access/login" 2 | ACCOUNT_LOGOUT = "/api/access/logout" 3 | ACCOUNT_REGISTER = "/api/access/signup" 4 | ACCOUNT_PROFILE = "/api/me" 5 | ACCOUNT_LIST = "/api/users" 6 | ACCOUNT_DELETE = "/api/users/{user_id:uuid}" 7 | ACCOUNT_DETAIL = "/api/users/{user_id:uuid}" 8 | ACCOUNT_UPDATE = "/api/users/{user_id:uuid}" 9 | ACCOUNT_CREATE = "/api/users" 10 | ACCOUNT_ASSIGN_ROLE = "/api/roles/{role_slug:str}/assign" 11 | ACCOUNT_REVOKE_ROLE = "/api/roles/{role_slug:str}/revoke" 12 | -------------------------------------------------------------------------------- /src/app/domain/system/__init__.py: -------------------------------------------------------------------------------- 1 | from . import controllers, schemas, tasks 2 | 3 | __all__ = ("controllers", "schemas", "tasks") 4 | -------------------------------------------------------------------------------- /src/app/domain/system/controllers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Literal, TypeVar 4 | 5 | import structlog 6 | from litestar import Controller, MediaType, Request, get 7 | from litestar.response import Response 8 | from redis import RedisError 9 | from sqlalchemy import text 10 | 11 | from app.config.base import get_settings 12 | 13 | from .schemas import SystemHealth 14 | from .urls import SYSTEM_HEALTH 15 | 16 | if TYPE_CHECKING: 17 | from litestar_saq import TaskQueues 18 | from sqlalchemy.ext.asyncio import AsyncSession 19 | 20 | logger = structlog.get_logger() 21 | OnlineOffline = TypeVar("OnlineOffline", bound=Literal["online", "offline"]) 22 | 23 | 24 | class SystemController(Controller): 25 | tags = ["System"] 26 | 27 | @get( 28 | operation_id="SystemHealth", 29 | name="system:health", 30 | path=SYSTEM_HEALTH, 31 | media_type=MediaType.JSON, 32 | cache=False, 33 | tags=["System"], 34 | summary="Health Check", 35 | description="Execute a health check against backend components. Returns system information including database and cache status.", 36 | ) 37 | async def check_system_health( 38 | self, 39 | request: Request, 40 | db_session: AsyncSession, 41 | task_queues: TaskQueues, 42 | ) -> Response[SystemHealth]: 43 | """Check database available and returns app config info.""" 44 | settings = get_settings() 45 | try: 46 | await db_session.execute(text("select 1")) 47 | db_ping = True 48 | except ConnectionRefusedError: 49 | db_ping = False 50 | 51 | db_status = "online" if db_ping else "offline" 52 | try: 53 | cache_ping = await settings.redis.get_client().ping() 54 | except RedisError: 55 | cache_ping = False 56 | cache_status = "online" if cache_ping else "offline" 57 | healthy = cache_ping and db_ping 58 | if healthy: 59 | await logger.adebug( 60 | "System Health", 61 | database_status=db_status, 62 | cache_status=cache_status, 63 | ) 64 | else: 65 | await logger.awarn( 66 | "System Health Check", 67 | database_status=db_status, 68 | cache_status=cache_status, 69 | ) 70 | 71 | return Response( 72 | content=SystemHealth(database_status=db_status, cache_status=cache_status), # type: ignore 73 | status_code=200 if db_ping and cache_ping else 500, 74 | media_type=MediaType.JSON, 75 | ) 76 | -------------------------------------------------------------------------------- /src/app/domain/system/schemas.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Literal 3 | 4 | from app.__about__ import __version__ as current_version 5 | from app.config.base import get_settings 6 | 7 | __all__ = ("SystemHealth",) 8 | 9 | settings = get_settings() 10 | 11 | 12 | @dataclass 13 | class SystemHealth: 14 | database_status: Literal["online", "offline"] 15 | cache_status: Literal["online", "offline"] 16 | app: str = settings.app.NAME 17 | version: str = current_version 18 | -------------------------------------------------------------------------------- /src/app/domain/system/tasks.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from saq.types import Context 4 | from structlog import get_logger 5 | 6 | __all__ = ["background_worker_task", "system_task", "system_upkeep"] 7 | 8 | 9 | logger = get_logger() 10 | 11 | 12 | async def system_upkeep(_: Context) -> None: 13 | await logger.ainfo("Performing system upkeep operations.") 14 | await logger.ainfo("Simulating a long running operation. Sleeping for 60 seconds.") 15 | await asyncio.sleep(60) 16 | await logger.ainfo("Simulating an even long running operation. Sleeping for 120 seconds.") 17 | await asyncio.sleep(120) 18 | await logger.ainfo("Long running process complete.") 19 | await logger.ainfo("Performing system upkeep operations.") 20 | 21 | 22 | async def background_worker_task(_: Context) -> None: 23 | await logger.ainfo("Performing background worker task.") 24 | await asyncio.sleep(20) 25 | await logger.ainfo("Performing system upkeep operations.") 26 | 27 | 28 | async def system_task(_: Context) -> None: 29 | await logger.ainfo("Performing simple system task") 30 | await asyncio.sleep(2) 31 | await logger.ainfo("System task complete.") 32 | -------------------------------------------------------------------------------- /src/app/domain/system/urls.py: -------------------------------------------------------------------------------- 1 | SYSTEM_HEALTH: str = "/health" 2 | """Default path for the service health check endpoint.""" 3 | -------------------------------------------------------------------------------- /src/app/domain/tags/__init__.py: -------------------------------------------------------------------------------- 1 | from . import controllers, services, urls 2 | 3 | __all__ = ["controllers", "services", "urls"] 4 | -------------------------------------------------------------------------------- /src/app/domain/tags/services.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from advanced_alchemy.repository import SQLAlchemyAsyncRepository 4 | from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService 5 | 6 | from app.db import models as m 7 | 8 | __all__ = ("TagService",) 9 | 10 | 11 | class TagService(SQLAlchemyAsyncRepositoryService[m.Tag]): 12 | """Handles basic lookup operations for an Tag.""" 13 | 14 | class Repository(SQLAlchemyAsyncRepository[m.Tag]): 15 | """Tag Repository.""" 16 | 17 | model_type = m.Tag 18 | 19 | repository_type = Repository 20 | match_fields = ["name"] 21 | -------------------------------------------------------------------------------- /src/app/domain/tags/urls.py: -------------------------------------------------------------------------------- 1 | TAG_LIST = "/api/tags" 2 | TAG_CREATE = "/api/tags" 3 | TAG_UPDATE = "/api/tags/{tag_id:uuid}" 4 | TAG_DELETE = "/api/tags/{tag_id:uuid}" 5 | TAG_DETAILS = "/api/tags/{tag_id:uuid}" 6 | -------------------------------------------------------------------------------- /src/app/domain/teams/__init__.py: -------------------------------------------------------------------------------- 1 | """Team Application Module.""" 2 | 3 | from . import controllers, guards, schemas, services, signals, urls 4 | 5 | __all__ = ("controllers", "guards", "schemas", "services", "signals", "urls") 6 | -------------------------------------------------------------------------------- /src/app/domain/teams/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | from .team_invitation import TeamInvitationController 2 | from .team_member import TeamMemberController 3 | from .teams import TeamController 4 | 5 | __all__ = ["TeamController", "TeamInvitationController", "TeamMemberController"] 6 | -------------------------------------------------------------------------------- /src/app/domain/teams/controllers/team_invitation.py: -------------------------------------------------------------------------------- 1 | """User Account Controllers.""" 2 | 3 | from __future__ import annotations 4 | 5 | from litestar import Controller 6 | 7 | from app.domain.teams.services import TeamInvitationService 8 | from app.lib.deps import create_service_provider 9 | 10 | 11 | class TeamInvitationController(Controller): 12 | """Team Invitations.""" 13 | 14 | tags = ["Teams"] 15 | dependencies = {"team_invitations_service": create_service_provider(TeamInvitationService)} 16 | -------------------------------------------------------------------------------- /src/app/domain/teams/controllers/team_member.py: -------------------------------------------------------------------------------- 1 | """User Account Controllers.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | from advanced_alchemy.exceptions import IntegrityError 8 | from litestar import Controller, post 9 | from litestar.di import Provide 10 | from litestar.params import Parameter 11 | from sqlalchemy.orm import contains_eager, selectinload 12 | 13 | from app.db import models as m 14 | from app.domain.accounts.deps import provide_users_service 15 | from app.domain.teams import urls 16 | from app.domain.teams.schemas import Team, TeamMemberModify 17 | from app.domain.teams.services import TeamMemberService, TeamService 18 | from app.lib.deps import create_service_provider 19 | 20 | if TYPE_CHECKING: 21 | from uuid import UUID 22 | 23 | from app.domain.accounts.services import UserService 24 | 25 | 26 | class TeamMemberController(Controller): 27 | """Team Members.""" 28 | 29 | tags = ["Team Members"] 30 | dependencies = { 31 | "teams_service": create_service_provider(TeamService, load=[m.Team.tags, m.Team.members]), 32 | "team_members_service": create_service_provider( 33 | TeamMemberService, 34 | load=[ 35 | selectinload(m.TeamMember.team).options(contains_eager(m.Team.tags)), 36 | selectinload(m.TeamMember.user), 37 | ], 38 | ), 39 | "users_service": Provide(provide_users_service), 40 | } 41 | 42 | @post(operation_id="AddMemberToTeam", path=urls.TEAM_ADD_MEMBER) 43 | async def add_member_to_team( 44 | self, 45 | teams_service: TeamService, 46 | users_service: UserService, 47 | data: TeamMemberModify, 48 | team_id: UUID = Parameter(title="Team ID", description="The team to update."), 49 | ) -> Team: 50 | """Add a member to a team.""" 51 | team_obj = await teams_service.get(team_id) 52 | user_obj = await users_service.get_one(email=data.user_name) 53 | is_member = any(membership.team.id == team_id for membership in user_obj.teams) 54 | if is_member: 55 | msg = "User is already a member of the team." 56 | raise IntegrityError(msg) 57 | team_obj.members.append(m.TeamMember(user_id=user_obj.id, role=m.TeamRoles.MEMBER)) 58 | team_obj = await teams_service.update(item_id=team_id, data=team_obj) 59 | return teams_service.to_schema(schema_type=Team, data=team_obj) 60 | 61 | @post(operation_id="RemoveMemberFromTeam", path=urls.TEAM_REMOVE_MEMBER) 62 | async def remove_member_from_team( 63 | self, 64 | teams_service: TeamService, 65 | team_members_service: TeamMemberService, 66 | users_service: UserService, 67 | data: TeamMemberModify, 68 | team_id: UUID = Parameter(title="Team ID", description="The team to delete."), 69 | ) -> Team: 70 | """Revoke a members access to a team.""" 71 | user_obj = await users_service.get_one(email=data.user_name) 72 | removed_member = False 73 | for membership in user_obj.teams: 74 | if membership.user_id == user_obj.id: 75 | removed_member = True 76 | _ = await team_members_service.delete(membership.id) 77 | if not removed_member: 78 | msg = "User is not a member of this team." 79 | raise IntegrityError(msg) 80 | team_obj = await teams_service.get(team_id) 81 | return teams_service.to_schema(schema_type=Team, data=team_obj) 82 | -------------------------------------------------------------------------------- /src/app/domain/teams/guards.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from litestar.connection import ASGIConnection 4 | from litestar.exceptions import PermissionDeniedException 5 | from litestar.handlers.base import BaseRouteHandler 6 | 7 | from app.config import constants 8 | from app.db.models import TeamRoles 9 | 10 | __all__ = ["requires_team_admin", "requires_team_membership", "requires_team_ownership"] 11 | 12 | 13 | def requires_team_membership(connection: ASGIConnection, _: BaseRouteHandler) -> None: 14 | """Verify the connection user is a member of the team. 15 | 16 | Args: 17 | connection (ASGIConnection): _description_ 18 | _ (BaseRouteHandler): _description_ 19 | 20 | Raises: 21 | PermissionDeniedException: _description_ 22 | """ 23 | team_id = connection.path_params["team_id"] 24 | has_system_role = any( 25 | assigned_role.role_name 26 | for assigned_role in connection.user.roles 27 | if assigned_role.role.name in {constants.SUPERUSER_ACCESS_ROLE} 28 | ) 29 | has_team_role = any(membership.team.id == team_id for membership in connection.user.teams) 30 | if connection.user.is_superuser or has_system_role or has_team_role: 31 | return 32 | raise PermissionDeniedException(detail="Insufficient permissions to access team.") 33 | 34 | 35 | def requires_team_admin(connection: ASGIConnection, _: BaseRouteHandler) -> None: 36 | """Verify the connection user is a team admin. 37 | 38 | Args: 39 | connection (ASGIConnection): _description_ 40 | _ (BaseRouteHandler): _description_ 41 | 42 | Raises: 43 | PermissionDeniedException: _description_ 44 | """ 45 | team_id = connection.path_params["team_id"] 46 | has_system_role = any( 47 | assigned_role.role_name 48 | for assigned_role in connection.user.roles 49 | if assigned_role.role.name in {constants.SUPERUSER_ACCESS_ROLE} 50 | ) 51 | has_team_role = any( 52 | membership.team.id == team_id and membership.role == TeamRoles.ADMIN for membership in connection.user.teams 53 | ) 54 | if connection.user.is_superuser or has_system_role or has_team_role: 55 | return 56 | raise PermissionDeniedException(detail="Insufficient permissions to access team.") 57 | 58 | 59 | def requires_team_ownership(connection: ASGIConnection, _: BaseRouteHandler) -> None: 60 | """Verify that the connection user is the team owner. 61 | 62 | Args: 63 | connection (ASGIConnection): _description_ 64 | _ (BaseRouteHandler): _description_ 65 | 66 | Raises: 67 | PermissionDeniedException: _description_ 68 | """ 69 | team_id = UUID(connection.path_params["team_id"]) 70 | has_system_role = any( 71 | assigned_role.role.name 72 | for assigned_role in connection.user.roles 73 | if assigned_role.role.name in {constants.SUPERUSER_ACCESS_ROLE} 74 | ) 75 | has_team_role = any(membership.team.id == team_id and membership.is_owner for membership in connection.user.teams) 76 | if connection.user.is_superuser or has_system_role or has_team_role: 77 | return 78 | 79 | msg = "Insufficient permissions to access team." 80 | raise PermissionDeniedException(detail=msg) 81 | -------------------------------------------------------------------------------- /src/app/domain/teams/schemas.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from uuid import UUID # noqa: TC003 4 | 5 | import msgspec 6 | 7 | from app.db.models.team_roles import TeamRoles 8 | from app.lib.schema import CamelizedBaseStruct 9 | 10 | 11 | class TeamTag(CamelizedBaseStruct): 12 | id: UUID 13 | slug: str 14 | name: str 15 | 16 | 17 | class TeamMember(CamelizedBaseStruct): 18 | id: UUID 19 | user_id: UUID 20 | email: str 21 | name: str | None = None 22 | role: TeamRoles | None = TeamRoles.MEMBER 23 | is_owner: bool | None = False 24 | 25 | 26 | class Team(CamelizedBaseStruct): 27 | id: UUID 28 | name: str 29 | description: str | None = None 30 | members: list[TeamMember] = [] 31 | tags: list[TeamTag] = [] 32 | 33 | 34 | class TeamCreate(CamelizedBaseStruct): 35 | name: str 36 | description: str | None = None 37 | tags: list[str] = [] 38 | 39 | 40 | class TeamUpdate(CamelizedBaseStruct, omit_defaults=True): 41 | name: str | None | msgspec.UnsetType = msgspec.UNSET 42 | description: str | None | msgspec.UnsetType = msgspec.UNSET 43 | tags: list[str] | None | msgspec.UnsetType = msgspec.UNSET 44 | 45 | 46 | class TeamMemberModify(CamelizedBaseStruct): 47 | """Team Member Modify.""" 48 | 49 | user_name: str 50 | -------------------------------------------------------------------------------- /src/app/domain/teams/signals.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import structlog 6 | from litestar.events import listener 7 | 8 | from app.config.app import alchemy 9 | from app.domain.teams.services import TeamService 10 | from app.lib.deps import create_service_provider 11 | 12 | if TYPE_CHECKING: 13 | from uuid import UUID 14 | 15 | logger = structlog.get_logger() 16 | 17 | 18 | @listener("team_created") 19 | async def team_created_event_handler( 20 | team_id: UUID, 21 | ) -> None: 22 | """Executes when a new user is created. 23 | 24 | Args: 25 | team_id: The primary key of the team that was created. 26 | """ 27 | provide_team_service = create_service_provider(TeamService) 28 | await logger.ainfo("Running post signup flow.") 29 | async with alchemy.get_session() as db_session: 30 | service = await anext(provide_team_service(db_session)) 31 | obj = await service.get_one_or_none(id=team_id) 32 | if obj is None: 33 | await logger.aerror("Could not locate the specified team", id=team_id) 34 | else: 35 | await logger.ainfo("Found team", **obj.to_dict()) 36 | -------------------------------------------------------------------------------- /src/app/domain/teams/urls.py: -------------------------------------------------------------------------------- 1 | TEAM_LIST = "/api/teams" 2 | TEAM_DELETE = "/api/teams/{team_id:uuid}" 3 | TEAM_DETAIL = "/api/teams/{team_id:uuid}" 4 | TEAM_UPDATE = "/api/teams/{team_id:uuid}" 5 | TEAM_CREATE = "/api/teams" 6 | TEAM_INDEX = "/api/teams/{team_id:uuid}" 7 | TEAM_INVITATION_LIST = "/api/teams/{team_id:uuid}/invitations" 8 | TEAM_ADD_MEMBER = "/api/teams/{team_id:uuid}/members/add" 9 | TEAM_REMOVE_MEMBER = "/api/teams/{team_id:uuid}/members/remove" 10 | -------------------------------------------------------------------------------- /src/app/domain/web/__init__.py: -------------------------------------------------------------------------------- 1 | from . import controllers, templates 2 | 3 | __all__ = ["controllers", "templates"] 4 | -------------------------------------------------------------------------------- /src/app/domain/web/controllers.py: -------------------------------------------------------------------------------- 1 | from litestar import Controller, get 2 | from litestar.response import Template 3 | from litestar.status_codes import HTTP_200_OK 4 | 5 | from app.config import constants 6 | 7 | 8 | class WebController(Controller): 9 | """Web Controller.""" 10 | 11 | include_in_schema = False 12 | opt = {"exclude_from_auth": True} 13 | 14 | @get( 15 | path=[constants.SITE_INDEX, f"{constants.SITE_INDEX}/{{path:path}}"], 16 | operation_id="WebIndex", 17 | name="frontend:index", 18 | status_code=HTTP_200_OK, 19 | ) 20 | async def index(self, path: str | None = None) -> Template: 21 | """Serve site root.""" 22 | return Template(template_name="site/index.html.j2") 23 | -------------------------------------------------------------------------------- /src/app/domain/web/templates/email/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/src/app/domain/web/templates/email/.gitkeep -------------------------------------------------------------------------------- /src/app/domain/web/templates/site/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/src/app/domain/web/templates/site/.gitkeep -------------------------------------------------------------------------------- /src/app/domain/web/templates/site/index.html.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | {{ vite_hmr() }} 14 | {{ vite('resources/main.tsx') }} 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/app/lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/src/app/lib/__init__.py -------------------------------------------------------------------------------- /src/app/lib/crypt.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations # noqa: A005 2 | 3 | import asyncio 4 | import base64 5 | 6 | from passlib.context import CryptContext 7 | 8 | password_crypt_context = CryptContext(schemes=["argon2"], deprecated="auto") 9 | 10 | 11 | def get_encryption_key(secret: str) -> bytes: 12 | """Get Encryption Key. 13 | 14 | Args: 15 | secret (str): Secret key used for encryption 16 | 17 | Returns: 18 | bytes: a URL safe encoded version of secret 19 | """ 20 | if len(secret) <= 32: 21 | secret = f"{secret:<32}"[:32] 22 | return base64.urlsafe_b64encode(secret.encode()) 23 | 24 | 25 | async def get_password_hash(password: str | bytes) -> str: 26 | """Get password hash. 27 | 28 | Args: 29 | password: Plain password 30 | Returns: 31 | str: Hashed password 32 | """ 33 | return await asyncio.get_running_loop().run_in_executor(None, password_crypt_context.hash, password) 34 | 35 | 36 | async def verify_password(plain_password: str | bytes, hashed_password: str) -> bool: 37 | """Verify Password. 38 | 39 | Args: 40 | plain_password (str | bytes): The string or byte password 41 | hashed_password (str): the hash of the password 42 | 43 | Returns: 44 | bool: True if password matches hash. 45 | """ 46 | valid, _ = await asyncio.get_running_loop().run_in_executor( 47 | None, 48 | password_crypt_context.verify_and_update, 49 | plain_password, 50 | hashed_password, 51 | ) 52 | return bool(valid) 53 | -------------------------------------------------------------------------------- /src/app/lib/deps.py: -------------------------------------------------------------------------------- 1 | """Application dependency providers generators. 2 | 3 | This module contains functions to create dependency providers for services and filters. 4 | 5 | You should not have modify this module very often and should only be invoked under normal usage. 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | from advanced_alchemy.extensions.litestar.providers import ( 11 | DependencyCache, 12 | DependencyDefaults, 13 | create_filter_dependencies, 14 | create_service_dependencies, 15 | create_service_provider, 16 | dep_cache, 17 | ) 18 | 19 | __all__ = ( 20 | "DependencyCache", 21 | "DependencyDefaults", 22 | "create_filter_dependencies", 23 | "create_service_dependencies", 24 | "create_service_provider", 25 | "dep_cache", 26 | ) 27 | -------------------------------------------------------------------------------- /src/app/lib/dto.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Literal, TypeVar, overload 4 | 5 | from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO, SQLAlchemyDTOConfig 6 | from litestar.dto import DataclassDTO, dto_field 7 | from litestar.dto.config import DTOConfig 8 | from litestar.types.protocols import DataclassProtocol 9 | from sqlalchemy.orm import DeclarativeBase 10 | 11 | if TYPE_CHECKING: 12 | from collections.abc import Set as AbstractSet 13 | 14 | from litestar.dto import RenameStrategy 15 | 16 | __all__ = ("DTOConfig", "DataclassDTO", "SQLAlchemyDTO", "config", "dto_field") 17 | 18 | DTOT = TypeVar("DTOT", bound=DataclassProtocol | DeclarativeBase) 19 | DTOFactoryT = TypeVar("DTOFactoryT", bound=DataclassDTO | SQLAlchemyDTO) 20 | SQLAlchemyModelT = TypeVar("SQLAlchemyModelT", bound=DeclarativeBase) 21 | DataclassModelT = TypeVar("DataclassModelT", bound=DataclassProtocol) 22 | ModelT = SQLAlchemyModelT | DataclassModelT 23 | 24 | 25 | @overload 26 | def config( 27 | backend: Literal["sqlalchemy"] = "sqlalchemy", 28 | exclude: AbstractSet[str] | None = None, 29 | rename_fields: dict[str, str] | None = None, 30 | rename_strategy: RenameStrategy | None = None, 31 | max_nested_depth: int | None = None, 32 | partial: bool | None = None, 33 | ) -> SQLAlchemyDTOConfig: ... 34 | 35 | 36 | @overload 37 | def config( 38 | backend: Literal["dataclass"] = "dataclass", 39 | exclude: AbstractSet[str] | None = None, 40 | rename_fields: dict[str, str] | None = None, 41 | rename_strategy: RenameStrategy | None = None, 42 | max_nested_depth: int | None = None, 43 | partial: bool | None = None, 44 | ) -> DTOConfig: ... 45 | 46 | 47 | def config( 48 | backend: Literal["dataclass", "sqlalchemy"] = "dataclass", 49 | exclude: AbstractSet[str] | None = None, 50 | rename_fields: dict[str, str] | None = None, 51 | rename_strategy: RenameStrategy | None = None, 52 | max_nested_depth: int | None = None, 53 | partial: bool | None = None, 54 | ) -> DTOConfig | SQLAlchemyDTOConfig: 55 | """_summary_ 56 | 57 | Returns: 58 | DTOConfig: Configured DTO class 59 | """ 60 | default_kwargs = {"rename_strategy": "camel", "max_nested_depth": 2} 61 | if exclude: 62 | default_kwargs["exclude"] = exclude 63 | if rename_fields: 64 | default_kwargs["rename_fields"] = rename_fields 65 | if rename_strategy: 66 | default_kwargs["rename_strategy"] = rename_strategy 67 | if max_nested_depth: 68 | default_kwargs["max_nested_depth"] = max_nested_depth 69 | if partial: 70 | default_kwargs["partial"] = partial 71 | return DTOConfig(**default_kwargs) 72 | -------------------------------------------------------------------------------- /src/app/lib/schema.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import msgspec 4 | 5 | 6 | class BaseStruct(msgspec.Struct): 7 | def to_dict(self) -> dict[str, Any]: 8 | return {f: getattr(self, f) for f in self.__struct_fields__ if getattr(self, f, None) != msgspec.UNSET} 9 | 10 | 11 | class CamelizedBaseStruct(BaseStruct, rename="camel"): 12 | """Camelized Base Struct""" 13 | 14 | 15 | class Message(CamelizedBaseStruct): 16 | message: str 17 | -------------------------------------------------------------------------------- /src/app/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/src/app/py.typed -------------------------------------------------------------------------------- /src/app/server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/src/app/server/__init__.py -------------------------------------------------------------------------------- /src/app/server/plugins.py: -------------------------------------------------------------------------------- 1 | from advanced_alchemy.extensions.litestar import SQLAlchemyPlugin 2 | from litestar.plugins.problem_details import ProblemDetailsPlugin 3 | from litestar.plugins.structlog import StructlogPlugin 4 | from litestar_granian import GranianPlugin 5 | from litestar_saq import SAQPlugin 6 | from litestar_vite import VitePlugin 7 | 8 | from app.config import app as config 9 | from app.lib.oauth import OAuth2ProviderPlugin 10 | 11 | structlog = StructlogPlugin(config=config.log) 12 | vite = VitePlugin(config=config.vite) 13 | saq = SAQPlugin(config=config.saq) 14 | alchemy = SQLAlchemyPlugin(config=config.alchemy) 15 | granian = GranianPlugin() 16 | problem_details = ProblemDetailsPlugin(config=config.problem_details) 17 | oauth = OAuth2ProviderPlugin() 18 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | const { fontFamily } = require("tailwindcss/defaultTheme") 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | darkMode: ["class"], 6 | content: [ 7 | "src/app/domain/web/{resources,templates}/**/*.{js,jsx,ts,cjs,mjs,tsx,vue,j2,html,htm}", 8 | "{resources,templates}/**/*.{js,cjs,mjs,jsx,ts,tsx,vue,j2,html,htm}", 9 | ], 10 | theme: { 11 | container: { 12 | center: true, 13 | padding: "2rem", 14 | screens: { 15 | "2xl": "1400px", 16 | }, 17 | }, 18 | extend: { 19 | colors: { 20 | border: "hsl(var(--border))", 21 | input: "hsl(var(--input))", 22 | ring: "hsl(var(--ring))", 23 | background: "hsl(var(--background))", 24 | foreground: "hsl(var(--foreground))", 25 | primary: { 26 | DEFAULT: "hsl(var(--primary))", 27 | foreground: "hsl(var(--primary-foreground))", 28 | }, 29 | secondary: { 30 | DEFAULT: "hsl(var(--secondary))", 31 | foreground: "hsl(var(--secondary-foreground))", 32 | }, 33 | destructive: { 34 | DEFAULT: "hsl(var(--destructive))", 35 | foreground: "hsl(var(--destructive-foreground))", 36 | }, 37 | muted: { 38 | DEFAULT: "hsl(var(--muted))", 39 | foreground: "hsl(var(--muted-foreground))", 40 | }, 41 | accent: { 42 | DEFAULT: "hsl(var(--accent))", 43 | foreground: "hsl(var(--accent-foreground))", 44 | }, 45 | popover: { 46 | DEFAULT: "hsl(var(--popover))", 47 | foreground: "hsl(var(--popover-foreground))", 48 | }, 49 | card: { 50 | DEFAULT: "hsl(var(--card))", 51 | foreground: "hsl(var(--card-foreground))", 52 | }, 53 | }, 54 | borderRadius: { 55 | lg: `var(--radius)`, 56 | md: `calc(var(--radius) - 2px)`, 57 | sm: "calc(var(--radius) - 4px)", 58 | }, 59 | keyframes: { 60 | "accordion-down": { 61 | from: { height: "0" }, 62 | to: { height: "var(--radix-accordion-content-height)" }, 63 | }, 64 | "accordion-up": { 65 | from: { height: "var(--radix-accordion-content-height)" }, 66 | to: { height: "0" }, 67 | }, 68 | }, 69 | animation: { 70 | "accordion-down": "accordion-down 0.2s ease-out", 71 | "accordion-up": "accordion-up 0.2s ease-out", 72 | }, 73 | }, 74 | }, 75 | plugins: [ 76 | require("tailwindcss-animate"), 77 | require("@tailwindcss/forms"), 78 | require("@tailwindcss/typography"), 79 | ], 80 | } 81 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | from redis.asyncio import Redis 7 | 8 | from app.config import base 9 | 10 | if TYPE_CHECKING: 11 | from collections.abc import AsyncGenerator 12 | 13 | from pytest import MonkeyPatch 14 | from pytest_databases.docker.redis import RedisService 15 | 16 | 17 | pytestmark = pytest.mark.anyio 18 | pytest_plugins = [ 19 | "tests.data_fixtures", 20 | "pytest_databases.docker", 21 | "pytest_databases.docker.postgres", 22 | "pytest_databases.docker.redis", 23 | ] 24 | 25 | 26 | @pytest.fixture(scope="session") 27 | def anyio_backend() -> str: 28 | return "asyncio" 29 | 30 | 31 | @pytest.fixture(autouse=True) 32 | def _patch_settings(monkeypatch: MonkeyPatch) -> None: 33 | """Path the settings.""" 34 | 35 | settings = base.Settings.from_env(".env.testing") 36 | 37 | def get_settings(dotenv_filename: str = ".env.testing") -> base.Settings: 38 | return settings 39 | 40 | monkeypatch.setattr(base, "get_settings", get_settings) 41 | 42 | 43 | @pytest.fixture(name="redis", autouse=True) 44 | async def fx_redis(redis_service: RedisService) -> AsyncGenerator[Redis, None]: 45 | """Redis instance for testing. 46 | 47 | Returns: 48 | Redis client instance, function scoped. 49 | """ 50 | yield Redis(host=redis_service.host, port=redis_service.port) 51 | -------------------------------------------------------------------------------- /tests/data_fixtures.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | import pytest 6 | 7 | if TYPE_CHECKING: 8 | from litestar import Litestar 9 | from pytest import MonkeyPatch 10 | 11 | from app.db.models import Team, User 12 | 13 | pytestmark = pytest.mark.anyio 14 | 15 | 16 | @pytest.fixture(name="app") 17 | def fx_app(pytestconfig: pytest.Config, monkeypatch: MonkeyPatch) -> Litestar: 18 | """App fixture. 19 | 20 | Returns: 21 | An application instance, configured via plugin. 22 | """ 23 | from app.asgi import create_app 24 | 25 | return create_app() 26 | 27 | 28 | @pytest.fixture(name="raw_users") 29 | def fx_raw_users() -> list[User | dict[str, Any]]: 30 | """Unstructured user representations.""" 31 | 32 | return [ 33 | { 34 | "id": "97108ac1-ffcb-411d-8b1e-d9183399f63b", 35 | "email": "superuser@example.com", 36 | "name": "Super User", 37 | "password": "Test_Password1!", 38 | "is_superuser": True, 39 | "is_active": True, 40 | }, 41 | { 42 | "id": "5ef29f3c-3560-4d15-ba6b-a2e5c721e4d2", 43 | "email": "user@example.com", 44 | "name": "Example User", 45 | "password": "Test_Password2!", 46 | "is_superuser": False, 47 | "is_active": True, 48 | }, 49 | { 50 | "id": "5ef29f3c-3560-4d15-ba6b-a2e5c721e999", 51 | "email": "test@test.com", 52 | "name": "Test User", 53 | "password": "Test_Password3!", 54 | "is_superuser": False, 55 | "is_active": True, 56 | }, 57 | { 58 | "id": "6ef29f3c-3560-4d15-ba6b-a2e5c721e4d3", 59 | "email": "another@example.com", 60 | "name": "The User", 61 | "password": "Test_Password3!", 62 | "is_superuser": False, 63 | "is_active": True, 64 | }, 65 | { 66 | "id": "7ef29f3c-3560-4d15-ba6b-a2e5c721e4e1", 67 | "email": "inactive@example.com", 68 | "name": "Inactive User", 69 | "password": "Old_Password2!", 70 | "is_superuser": False, 71 | "is_active": False, 72 | }, 73 | ] 74 | 75 | 76 | @pytest.fixture(name="raw_teams") 77 | def fx_raw_teams() -> list[Team | dict[str, Any]]: 78 | """Unstructured team representations.""" 79 | 80 | return [ 81 | { 82 | "id": "97108ac1-ffcb-411d-8b1e-d9183399f63b", 83 | "slug": "test-team", 84 | "name": "Test Team", 85 | "description": "This is a description for a team.", 86 | "owner_id": "5ef29f3c-3560-4d15-ba6b-a2e5c721e4d2", 87 | }, 88 | { 89 | "id": "81108ac1-ffcb-411d-8b1e-d91833999999", 90 | "slug": "simple-team", 91 | "name": "Simple Team", 92 | "description": "This is a description", 93 | "owner_id": "5ef29f3c-3560-4d15-ba6b-a2e5c721e999", 94 | "tags": ["new", "another", "extra"], 95 | }, 96 | { 97 | "id": "81108ac1-ffcb-411d-8b1e-d91833999998", 98 | "slug": "extra-team", 99 | "name": "Extra Team", 100 | "description": "This is a description", 101 | "owner_id": "5ef29f3c-3560-4d15-ba6b-a2e5c721e999", 102 | "tags": ["extra"], 103 | }, 104 | ] 105 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | from contextlib import AbstractAsyncContextManager, AbstractContextManager 5 | from functools import partial 6 | from typing import TYPE_CHECKING, TypeVar, cast, overload 7 | 8 | from anyio import to_thread 9 | from typing_extensions import ParamSpec 10 | 11 | if TYPE_CHECKING: 12 | from collections.abc import Awaitable, Callable 13 | from types import TracebackType 14 | 15 | T = TypeVar("T") 16 | P = ParamSpec("P") 17 | 18 | 19 | class _ContextManagerWrapper: 20 | def __init__(self, cm: AbstractContextManager[object]) -> None: 21 | self._cm = cm 22 | 23 | async def __aenter__(self) -> object: 24 | return self._cm.__enter__() 25 | 26 | async def __aexit__( 27 | self, 28 | exc_type: type[BaseException] | None, 29 | exc_val: BaseException | None, 30 | exc_tb: TracebackType | None, 31 | ) -> bool | None: 32 | return self._cm.__exit__(exc_type, exc_val, exc_tb) 33 | 34 | 35 | @overload 36 | async def maybe_async(obj: Awaitable[T]) -> T: ... 37 | 38 | 39 | @overload 40 | async def maybe_async(obj: T) -> T: ... 41 | 42 | 43 | async def maybe_async(obj: Awaitable[T] | T) -> T: 44 | return cast(T, await obj) if inspect.isawaitable(obj) else cast(T, obj) # type: ignore[redundant-cast] 45 | 46 | 47 | def maybe_async_cm(obj: AbstractContextManager[T] | AbstractAsyncContextManager[T]) -> AbstractAsyncContextManager[T]: 48 | if isinstance(obj, AbstractContextManager): 49 | return cast(AbstractAsyncContextManager[T], _ContextManagerWrapper(obj)) 50 | return obj 51 | 52 | 53 | def wrap_sync(fn: Callable[P, T]) -> Callable[P, Awaitable[T]]: 54 | if inspect.iscoroutinefunction(fn): 55 | return fn 56 | 57 | async def wrapped(*args: P.args, **kwargs: P.kwargs) -> T: 58 | return await to_thread.run_sync(partial(fn, *args, **kwargs)) 59 | 60 | return wrapped 61 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/test_access.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from httpx import AsyncClient 3 | 4 | pytestmark = pytest.mark.anyio 5 | 6 | 7 | @pytest.mark.parametrize( 8 | ("username", "password", "expected_status_code"), 9 | ( 10 | ("superuser@example1.com", "Test_Password1!", 403), 11 | ("superuser@example.com", "Test_Password1!", 201), 12 | ("user@example.com", "Test_Password1!", 403), 13 | ("user@example.com", "Test_Password2!", 201), 14 | ("inactive@example.com", "Old_Password2!", 403), 15 | ("inactive@example.com", "Old_Password3!", 403), 16 | ), 17 | ) 18 | async def test_user_login(client: AsyncClient, username: str, password: str, expected_status_code: int) -> None: 19 | response = await client.post("/api/access/login", data={"username": username, "password": password}) 20 | assert response.status_code == expected_status_code 21 | 22 | 23 | @pytest.mark.parametrize( 24 | ("username", "password"), 25 | (("superuser@example.com", "Test_Password1!"),), 26 | ) 27 | async def test_user_logout(client: AsyncClient, username: str, password: str) -> None: 28 | response = await client.post("/api/access/login", data={"username": username, "password": password}) 29 | assert response.status_code == 201 30 | cookies = dict(response.cookies) 31 | 32 | assert cookies.get("token") is not None 33 | 34 | me_response = await client.get("/api/me") 35 | assert me_response.status_code == 200 36 | 37 | response = await client.post("/api/access/logout") 38 | assert response.status_code == 200 39 | 40 | # the user can no longer access the /me route. 41 | me_response = await client.get("/api/me") 42 | assert me_response.status_code == 401 43 | -------------------------------------------------------------------------------- /tests/integration/test_account_role.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | if TYPE_CHECKING: 8 | from httpx import AsyncClient 9 | 10 | 11 | pytestmark = pytest.mark.anyio 12 | 13 | 14 | async def test_superuser_role_access( 15 | client: "AsyncClient", 16 | user_token_headers: dict[str, str], 17 | superuser_token_headers: dict[str, str], 18 | ) -> None: 19 | # user should not see all teams to start 20 | response = await client.get("/api/teams", headers=user_token_headers) 21 | assert response.status_code == 200 22 | assert int(response.json()["total"]) == 1 23 | 24 | # assign the role 25 | response = await client.post( 26 | "/api/roles/superuser/assign", 27 | json={"userName": "user@example.com"}, 28 | headers=superuser_token_headers, 29 | ) 30 | assert response.status_code == 201 31 | assert response.json()["message"] == "Successfully assigned the 'superuser' role to user@example.com." 32 | response = await client.patch( 33 | "/api/teams/81108ac1-ffcb-411d-8b1e-d91833999999", 34 | json={"name": "TEST UPDATE"}, 35 | headers=user_token_headers, 36 | ) 37 | assert response.status_code == 200 38 | # retrieve 39 | response = await client.get("/api/teams/81108ac1-ffcb-411d-8b1e-d91833999999", headers=user_token_headers) 40 | assert response.status_code == 200 41 | response = await client.get("/api/teams", headers=user_token_headers) 42 | assert response.status_code == 200 43 | assert int(response.json()["total"]) == 3 44 | 45 | # superuser should see all 46 | response = await client.get("/api/teams", headers=superuser_token_headers) 47 | assert response.status_code == 200 48 | assert int(response.json()["total"]) == 3 49 | # delete 50 | # revoke role now 51 | response = await client.post( 52 | "/api/roles/superuser/revoke", 53 | json={"userName": "user@example.com"}, 54 | headers=superuser_token_headers, 55 | ) 56 | assert response.status_code == 201 57 | response = await client.delete("/api/teams/81108ac1-ffcb-411d-8b1e-d91833999999", headers=user_token_headers) 58 | assert response.status_code == 403 59 | response = await client.delete("/api/teams/97108ac1-ffcb-411d-8b1e-d9183399f63b", headers=user_token_headers) 60 | assert response.status_code == 204 61 | 62 | # retrieve should now fail 63 | response = await client.get("/api/teams/81108ac1-ffcb-411d-8b1e-d91833999999", headers=user_token_headers) 64 | assert response.status_code == 403 65 | # user should only see 1 now. 66 | response = await client.get("/api/teams", headers=user_token_headers) 67 | assert response.status_code == 200 68 | assert int(response.json()["total"]) == 0 69 | -------------------------------------------------------------------------------- /tests/integration/test_accounts.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | import pytest 4 | 5 | if TYPE_CHECKING: 6 | from httpx import AsyncClient 7 | 8 | pytestmark = pytest.mark.anyio 9 | 10 | 11 | async def test_update_user_no_auth(client: "AsyncClient") -> None: 12 | response = await client.patch("/api/users/97108ac1-ffcb-411d-8b1e-d9183399f63b", json={"name": "TEST UPDATE"}) 13 | assert response.status_code == 401 14 | response = await client.post( 15 | "/api/users/", 16 | json={"name": "A User", "email": "new-user@example.com", "password": "S3cret!"}, 17 | ) 18 | assert response.status_code == 401 19 | response = await client.get("/api/users/97108ac1-ffcb-411d-8b1e-d9183399f63b") 20 | assert response.status_code == 401 21 | response = await client.get("/api/users") 22 | assert response.status_code == 401 23 | response = await client.delete("/api/users/97108ac1-ffcb-411d-8b1e-d9183399f63b") 24 | assert response.status_code == 401 25 | 26 | 27 | async def test_accounts_list(client: "AsyncClient", superuser_token_headers: dict[str, str]) -> None: 28 | response = await client.get("/api/users", headers=superuser_token_headers) 29 | assert response.status_code == 200 30 | assert int(response.json()["total"]) > 0 31 | 32 | 33 | async def test_accounts_get(client: "AsyncClient", superuser_token_headers: dict[str, str]) -> None: 34 | response = await client.get("/api/users/97108ac1-ffcb-411d-8b1e-d9183399f63b", headers=superuser_token_headers) 35 | assert response.status_code == 200 36 | assert response.json()["email"] == "superuser@example.com" 37 | 38 | 39 | async def test_accounts_create(client: "AsyncClient", superuser_token_headers: dict[str, str]) -> None: 40 | response = await client.post( 41 | "/api/users", 42 | json={"name": "A User", "email": "new-user@example.com", "password": "S3cret!"}, 43 | headers=superuser_token_headers, 44 | ) 45 | assert response.status_code == 201 46 | 47 | 48 | async def test_accounts_update(client: "AsyncClient", superuser_token_headers: dict[str, str]) -> None: 49 | response = await client.patch( 50 | "/api/users/5ef29f3c-3560-4d15-ba6b-a2e5c721e4d2", 51 | json={ 52 | "name": "Name Changed", 53 | }, 54 | headers=superuser_token_headers, 55 | ) 56 | assert response.status_code == 200 57 | assert response.json()["name"] == "Name Changed" 58 | 59 | 60 | async def test_accounts_delete(client: "AsyncClient", superuser_token_headers: dict[str, str]) -> None: 61 | response = await client.delete( 62 | "/api/users/5ef29f3c-3560-4d15-ba6b-a2e5c721e4d2", 63 | headers=superuser_token_headers, 64 | ) 65 | assert response.status_code == 204 66 | # ensure we didn't cascade delete the teams the user owned 67 | response = await client.get( 68 | "/api/teams/97108ac1-ffcb-411d-8b1e-d9183399f63b", 69 | headers=superuser_token_headers, 70 | ) 71 | assert response.status_code == 200 72 | 73 | 74 | async def test_accounts_with_incorrect_role(client: "AsyncClient", user_token_headers: dict[str, str]) -> None: 75 | response = await client.patch( 76 | "/api/users/97108ac1-ffcb-411d-8b1e-d9183399f63b", 77 | json={"name": "TEST UPDATE"}, 78 | headers=user_token_headers, 79 | ) 80 | assert response.status_code == 403 81 | response = await client.post( 82 | "/api/users/", 83 | json={"name": "A User", "email": "new-user@example.com", "password": "S3cret!"}, 84 | headers=user_token_headers, 85 | ) 86 | assert response.status_code == 403 87 | response = await client.get("/api/users/97108ac1-ffcb-411d-8b1e-d9183399f63b", headers=user_token_headers) 88 | assert response.status_code == 403 89 | response = await client.get("/api/users", headers=user_token_headers) 90 | assert response.status_code == 403 91 | response = await client.delete("/api/users/97108ac1-ffcb-411d-8b1e-d9183399f63b", headers=user_token_headers) 92 | assert response.status_code == 403 93 | -------------------------------------------------------------------------------- /tests/integration/test_health.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from httpx import AsyncClient 3 | 4 | from app.__about__ import __version__ 5 | 6 | pytestmark = pytest.mark.anyio 7 | 8 | 9 | @pytest.mark.xfail(reason="Flakey connection to service sometimes causes failures.") 10 | async def test_health(client: AsyncClient, valkey_service: None) -> None: 11 | response = await client.get("/health") 12 | assert response.status_code == 200 13 | 14 | expected = { 15 | "database_status": "online", 16 | "cache_status": "online", 17 | "app": "app", 18 | "version": __version__, 19 | } 20 | 21 | assert response.json() == expected 22 | -------------------------------------------------------------------------------- /tests/integration/test_tags.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | import pytest 4 | 5 | if TYPE_CHECKING: 6 | from httpx import AsyncClient 7 | 8 | pytestmark = pytest.mark.anyio 9 | 10 | 11 | async def test_tags_list(client: "AsyncClient", superuser_token_headers: dict[str, str]) -> None: 12 | response = await client.get("/api/tags", headers=superuser_token_headers) 13 | resj = response.json() 14 | assert response.status_code == 200 15 | assert int(resj["total"]) == 3 16 | -------------------------------------------------------------------------------- /tests/integration/test_tests.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, cast 4 | 5 | import pytest 6 | from litestar import get 7 | from litestar.testing import AsyncTestClient 8 | 9 | if TYPE_CHECKING: 10 | from litestar import Litestar 11 | from litestar.stores.redis import RedisStore 12 | from redis.asyncio import Redis as AsyncRedis 13 | from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession 14 | 15 | pytestmark = pytest.mark.anyio 16 | 17 | 18 | @pytest.mark.anyio 19 | async def test_cache_on_app(app: "Litestar", redis: "AsyncRedis") -> None: 20 | """Test that the app's cache is patched. 21 | 22 | Args: 23 | app: The test Litestar instance 24 | redis: The test Redis client instance. 25 | """ 26 | assert cast("RedisStore", app.stores.get("response_cache"))._redis is redis 27 | 28 | 29 | @pytest.mark.anyio 30 | async def test_db_session_dependency(app: "Litestar", engine: "AsyncEngine") -> None: 31 | """Test that handlers receive session attached to patched engine. 32 | 33 | Args: 34 | app: The test Litestar instance 35 | engine: The patched SQLAlchemy engine instance. 36 | """ 37 | 38 | @get("/db-session-test", opt={"exclude_from_auth": True}) 39 | async def db_session_dependency_patched(db_session: AsyncSession) -> dict[str, str]: 40 | return {"result": f"{db_session.bind is engine = }"} 41 | 42 | app.register(db_session_dependency_patched) 43 | # can't use test client as it always starts its own event loop 44 | async with AsyncTestClient(app) as client: 45 | response = await client.get("/db-session-test") 46 | assert response.json()["result"] == "db_session.bind is engine = True" 47 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | from litestar import Litestar, get 7 | from litestar.datastructures import State 8 | from litestar.enums import ScopeType 9 | from litestar.testing import AsyncTestClient 10 | 11 | if TYPE_CHECKING: 12 | from collections.abc import AsyncGenerator 13 | 14 | from litestar.types import HTTPResponseBodyEvent, HTTPResponseStartEvent, HTTPScope 15 | 16 | pytestmark = pytest.mark.anyio 17 | 18 | 19 | @pytest.fixture(name="client") 20 | async def fx_client(app: Litestar) -> AsyncGenerator[AsyncTestClient, None]: 21 | """Test client fixture for making calls on the global app instance.""" 22 | try: 23 | async with AsyncTestClient(app=app) as client: 24 | yield client 25 | except Exception: # noqa: BLE001 26 | ... 27 | 28 | 29 | @pytest.fixture() 30 | def http_response_start() -> HTTPResponseStartEvent: 31 | """ASGI message for start of response.""" 32 | return {"type": "http.response.start", "status": 200, "headers": []} 33 | 34 | 35 | @pytest.fixture() 36 | def http_response_body() -> HTTPResponseBodyEvent: 37 | """ASGI message for interim, and final response body messages. 38 | 39 | Note: 40 | `more_body` is `True` for interim body messages. 41 | """ 42 | return {"type": "http.response.body", "body": b"body", "more_body": False} 43 | 44 | 45 | @pytest.fixture() 46 | def state() -> State: 47 | """Litestar application state data structure.""" 48 | return State() 49 | 50 | 51 | @pytest.fixture() 52 | def http_scope(app: Litestar) -> HTTPScope: 53 | """Minimal ASGI HTTP connection scope.""" 54 | 55 | @get() 56 | async def handler() -> None: ... 57 | 58 | return { 59 | "headers": [], 60 | "app": app, 61 | "litestar_app": app, 62 | "asgi": {"spec_version": "whatever", "version": "3.0"}, 63 | "auth": None, 64 | "client": None, 65 | "extensions": None, 66 | "http_version": "3", 67 | "path": "/wherever", 68 | "path_params": {}, 69 | "query_string": b"", 70 | "raw_path": b"/wherever", 71 | "path_template": "template.j2", 72 | "root_path": "/", 73 | "route_handler": handler, 74 | "scheme": "http", 75 | "server": None, 76 | "session": {}, 77 | "state": {}, 78 | "user": None, 79 | "method": "GET", 80 | "type": ScopeType.HTTP, 81 | } 82 | -------------------------------------------------------------------------------- /tests/unit/lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/tests/unit/lib/__init__.py -------------------------------------------------------------------------------- /tests/unit/lib/test_cache.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from litestar.config.response_cache import default_cache_key_builder 3 | from litestar.testing import RequestFactory 4 | 5 | from app.server.core import ApplicationCore 6 | 7 | pytestmark = pytest.mark.anyio 8 | 9 | 10 | def test_cache_key_builder(monkeypatch: "pytest.MonkeyPatch") -> None: 11 | monkeypatch.setattr(ApplicationCore, "app_slug", "the-slug") 12 | request = RequestFactory().get("/test") 13 | default_cache_key = default_cache_key_builder(request) 14 | assert ApplicationCore()._cache_key_builder(request) == f"the-slug:{default_cache_key}" 15 | -------------------------------------------------------------------------------- /tests/unit/lib/test_crypt.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=protected-access 2 | from __future__ import annotations 3 | 4 | import base64 5 | 6 | import pytest 7 | 8 | from app.lib import crypt 9 | 10 | pytestmark = pytest.mark.anyio 11 | 12 | 13 | @pytest.mark.parametrize( 14 | ("secret_key", "expected_value"), 15 | ( 16 | ("test", "test "), 17 | ("test---------------------------", "test--------------------------- "), 18 | ("test----------------------------", "test----------------------------"), 19 | ("test-----------------------------", "test-----------------------------"), 20 | ( 21 | "this is a really long string that exceeds the 32 character padding added.", 22 | "this is a really long string that exceeds the 32 character padding added.", 23 | ), 24 | ), 25 | ) 26 | async def test_get_encryption_key(secret_key: str, expected_value: str) -> None: 27 | """Test that the encryption key is formatted correctly.""" 28 | secret = crypt.get_encryption_key(secret_key) 29 | decoded = base64.urlsafe_b64decode(secret) 30 | assert expected_value == decoded.decode() 31 | 32 | 33 | async def test_get_password_hash() -> None: 34 | """Test that the encryption key is formatted correctly.""" 35 | secret_str = "This is a password!" # noqa: S105 36 | secret_bytes = b"This is a password too!" 37 | secret_str_hash = await crypt.get_password_hash(secret_str) 38 | secret_bytes_hash = await crypt.get_password_hash(secret_bytes) 39 | 40 | assert secret_str_hash.startswith("$argon2") 41 | assert secret_bytes_hash.startswith("$argon2") 42 | 43 | 44 | @pytest.mark.parametrize( 45 | ("valid_password", "tested_password", "expected_result"), 46 | (("SuperS3cret123456789!!", "SuperS3cret123456789!!", True), ("SuperS3cret123456789!!", "Invalid!!", False)), 47 | ) 48 | async def test_verify_password(valid_password: str, tested_password: str, expected_result: bool) -> None: 49 | """Test that the encryption key is formatted correctly.""" 50 | 51 | secret_str_hash = await crypt.get_password_hash(valid_password) 52 | is_valid = await crypt.verify_password(tested_password, secret_str_hash) 53 | 54 | assert is_valid == expected_result 55 | -------------------------------------------------------------------------------- /tests/unit/lib/test_exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | from unittest.mock import ANY, MagicMock 3 | 4 | import pytest 5 | from litestar import Litestar, get 6 | from litestar.repository.exceptions import ConflictError, NotFoundError 7 | from litestar.status_codes import ( 8 | HTTP_403_FORBIDDEN, 9 | HTTP_404_NOT_FOUND, 10 | HTTP_409_CONFLICT, 11 | HTTP_500_INTERNAL_SERVER_ERROR, 12 | ) 13 | from litestar.testing import RequestFactory, create_test_client 14 | 15 | from app.lib import exceptions 16 | from app.lib.exceptions import ApplicationError 17 | 18 | if TYPE_CHECKING: 19 | from collections import abc 20 | 21 | 22 | pytestmark = pytest.mark.anyio 23 | 24 | 25 | def test_after_exception_hook_handler_called(monkeypatch: pytest.MonkeyPatch) -> None: 26 | """Tests that the handler gets added to the app and called.""" 27 | logger_mock = MagicMock() 28 | monkeypatch.setattr(exceptions, "bind_contextvars", logger_mock) 29 | exc = RuntimeError() 30 | 31 | @get("/error") 32 | async def raises() -> None: 33 | raise exc 34 | 35 | with create_test_client( 36 | route_handlers=[raises], 37 | after_exception=[exceptions.after_exception_hook_handler], 38 | ) as client: 39 | resp = client.get("/error") 40 | assert resp.status_code == HTTP_500_INTERNAL_SERVER_ERROR 41 | 42 | logger_mock.assert_called_once_with(exc_info=(RuntimeError, exc, ANY)) 43 | 44 | 45 | @pytest.mark.parametrize( 46 | ("exc", "status"), 47 | [ 48 | (ConflictError, HTTP_409_CONFLICT), 49 | (NotFoundError, HTTP_404_NOT_FOUND), 50 | (ApplicationError, HTTP_500_INTERNAL_SERVER_ERROR), 51 | ], 52 | ) 53 | def test_repository_exception_to_http_response(exc: type[ApplicationError], status: int) -> None: 54 | app = Litestar(route_handlers=[]) 55 | request = RequestFactory(app=app, server="testserver").get("/wherever") 56 | response = exceptions.exception_to_http_response(request, exc()) 57 | assert response.status_code == status 58 | 59 | 60 | @pytest.mark.parametrize( 61 | ("exc", "status", "debug"), 62 | [ 63 | (exceptions.AuthorizationError, HTTP_403_FORBIDDEN, True), 64 | (exceptions.AuthorizationError, HTTP_403_FORBIDDEN, False), 65 | (exceptions.ApplicationError, HTTP_500_INTERNAL_SERVER_ERROR, False), 66 | ], 67 | ) 68 | def test_exception_to_http_response(exc: type[exceptions.ApplicationError], status: int, debug: bool) -> None: 69 | app = Litestar(route_handlers=[], debug=debug) 70 | request = RequestFactory(app=app, server="testserver").get("/wherever") 71 | response = exceptions.exception_to_http_response(request, exc()) 72 | assert response.status_code == status 73 | 74 | 75 | @pytest.mark.parametrize( 76 | ("exc", "fn", "expected_message"), 77 | [ 78 | ( 79 | exceptions.ApplicationError("message"), 80 | exceptions.exception_to_http_response, 81 | b"app.lib.exceptions.ApplicationError: message\n", 82 | ), 83 | ], 84 | ) 85 | def test_exception_serves_debug_middleware_response( 86 | exc: Exception, 87 | fn: "abc.Callable", 88 | expected_message: bytes, 89 | ) -> None: 90 | app = Litestar(route_handlers=[], debug=True) 91 | request = RequestFactory(app=app, server="testserver").get("/wherever") 92 | response = fn(request, exc) 93 | assert response.content == expected_message.decode() 94 | -------------------------------------------------------------------------------- /tests/unit/lib/test_schema.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/tests/unit/lib/test_schema.py -------------------------------------------------------------------------------- /tests/unit/lib/test_settings.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.config import get_settings 4 | 5 | pytestmark = pytest.mark.anyio 6 | 7 | 8 | def test_app_slug() -> None: 9 | """Test app name conversion to slug.""" 10 | settings = get_settings() 11 | settings.app.NAME = "My Application!" 12 | assert settings.app.slug == "my-application" 13 | -------------------------------------------------------------------------------- /tests/unit/test_cli.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from click.testing import CliRunner 3 | 4 | 5 | @pytest.fixture() 6 | def cli_runner() -> CliRunner: 7 | return CliRunner() 8 | -------------------------------------------------------------------------------- /tools/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litestar-org/litestar-fullstack/0996536189cf48c0b7ce8c2c2600cb439968a377/tools/__init__.py -------------------------------------------------------------------------------- /tools/build_docs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import shutil 5 | import subprocess 6 | from contextlib import contextmanager 7 | from pathlib import Path 8 | from typing import TYPE_CHECKING 9 | 10 | if TYPE_CHECKING: 11 | from collections.abc import Generator 12 | 13 | REDIRECT_TEMPLATE = """ 14 | 15 | 16 | 17 | Page Redirection 18 | 19 | 20 | 21 | 22 | 23 | You are being redirected. If this does not work, click this link 24 | 25 | 26 | """ 27 | 28 | parser = argparse.ArgumentParser() 29 | parser.add_argument("output") 30 | 31 | 32 | @contextmanager 33 | def checkout(branch: str) -> Generator[None, None, None]: 34 | subprocess.run(["git", "checkout", branch], check=True) # noqa: S607 35 | yield 36 | subprocess.run(["git", "checkout", "-"], check=True) # noqa: S607 37 | 38 | 39 | def build(output_dir: str) -> None: 40 | subprocess.run(["make", "docs"], check=True) # noqa: S607 41 | 42 | output_dir = Path(output_dir) # type: ignore[assignment] 43 | output_dir.mkdir() # type: ignore[attr-defined] 44 | output_dir.joinpath(".nojekyll").touch(exist_ok=True) # type: ignore[attr-defined] 45 | output_dir.joinpath("index.html").write_text(REDIRECT_TEMPLATE.format(target="latest")) # type: ignore[attr-defined] 46 | 47 | docs_src_path = Path("docs/_build/html") 48 | shutil.copytree(docs_src_path, output_dir / "latest", dirs_exist_ok=True) # type: ignore[operator] 49 | 50 | 51 | def main() -> None: 52 | args = parser.parse_args() 53 | build(output_dir=args.output) 54 | 55 | 56 | if __name__ == "__main__": 57 | main() 58 | -------------------------------------------------------------------------------- /tools/manage_assets.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import logging 5 | import os 6 | import platform 7 | import subprocess 8 | import sys 9 | from importlib.util import find_spec 10 | from pathlib import Path 11 | from typing import Any 12 | 13 | NODEENV_INSTALLED = find_spec("nodeenv") is not None 14 | 15 | logger = logging.getLogger("manage_assets") 16 | 17 | PROJECT_ROOT = Path(__file__).parent.parent 18 | NODEENV = "nodeenv" 19 | DEFAULT_VENV_PATH = Path(PROJECT_ROOT / ".venv") 20 | 21 | 22 | def manage_resources(setup_kwargs: Any) -> Any: 23 | # look for this in the environment and skip this function if it exists, sometimes building here is not needed, eg. when using nixpacks 24 | no_nodeenv = os.environ.get("LITESTAR_SKIP_NODEENV_INSTALL") is not None or NODEENV_INSTALLED is False 25 | build_assets = setup_kwargs.pop("build_assets", None) 26 | install_packages = setup_kwargs.pop("install_packages", None) 27 | kwargs: dict[str, Any] = {} 28 | if no_nodeenv: 29 | logger.info("skipping nodeenv configuration") 30 | else: 31 | found_in_local_venv = Path(DEFAULT_VENV_PATH / "bin" / NODEENV).exists() 32 | nodeenv_command = f"{DEFAULT_VENV_PATH}/bin/{NODEENV}" if found_in_local_venv else NODEENV 33 | install_dir = DEFAULT_VENV_PATH if found_in_local_venv else os.environ.get("VIRTUAL_ENV", sys.prefix) 34 | logger.info("Installing Node environment to %s:", install_dir) 35 | subprocess.run([nodeenv_command, install_dir, "--force", "--quiet"], **kwargs) # noqa: PLW1510 36 | 37 | if platform.system() == "Windows": 38 | kwargs["shell"] = True 39 | if install_packages is not None: 40 | logger.info("Installing NPM packages.") 41 | subprocess.run(["npm", "install"], **kwargs) # noqa: S607, PLW1510 42 | if build_assets is not None: 43 | logger.info("Building NPM static assets.") 44 | subprocess.run(["npm", "run", "build"], **kwargs) # noqa: S607, PLW1510 45 | return setup_kwargs 46 | 47 | 48 | if __name__ == "__main__": 49 | parser = argparse.ArgumentParser("Manage Resources") 50 | parser.add_argument("--build-assets", action="store_true", help="Build assets for static hosting.", default=None) 51 | parser.add_argument("--install-packages", action="store_true", help="Install NPM packages.", default=None) 52 | args = parser.parse_args() 53 | setup_kwargs = {"build_assets": args.build_assets, "install_packages": args.install_packages} 54 | manage_resources(setup_kwargs) 55 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "noEmit": true, 14 | "jsx": "react-jsx", 15 | "skipLibCheck": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "baseUrl": "resources", 23 | "paths": { 24 | "@/*": ["./*"] 25 | }, 26 | "types": ["vite/client","node"] 27 | }, 28 | "include": [ 29 | "**/*.d.ts", 30 | "resources/**/*.tsx", 31 | "resources/**/*.jsx", 32 | "resources/**/*.js", 33 | "resources/**/*.ts", 34 | "vite.config.ts", 35 | "vite.config.js" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite" 2 | import path from "path" 3 | import litestar from "litestar-vite-plugin" 4 | import react from "@vitejs/plugin-react" 5 | 6 | const ASSET_URL = process.env.ASSET_URL || "/static/" 7 | const VITE_PORT = process.env.VITE_PORT || "5173" 8 | const VITE_HOST = process.env.VITE_HOST || "localhost" 9 | export default defineConfig({ 10 | base: `${ASSET_URL}`, 11 | clearScreen: false, 12 | publicDir: "public/", 13 | server: { 14 | host: "0.0.0.0", 15 | port: +`${VITE_PORT}`, 16 | cors: true, 17 | hmr: { 18 | host: `${VITE_HOST}`, 19 | }, 20 | }, 21 | plugins: [ 22 | react(), 23 | litestar({ 24 | input: ["resources/main.tsx"], 25 | assetUrl: `${ASSET_URL}`, 26 | bundleDirectory: "src/app/domain/web/public", 27 | resourceDirectory: "resources", 28 | hotFile: "src/app/domain/web/public/hot", 29 | }), 30 | ], 31 | resolve: { 32 | alias: { 33 | "@": path.resolve(__dirname, "resources"), 34 | }, 35 | }, 36 | }) 37 | --------------------------------------------------------------------------------