├── .devcontainer ├── backend │ └── devcontainer.json └── frontend │ └── devcontainer.json ├── .env.backend.template ├── .env.frontend.template ├── .github ├── dependabot.yml └── workflows │ ├── lint.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── settings.json └── tasks.json ├── LICENSE.md ├── README.md ├── backend ├── Dockerfile ├── api │ ├── __init__.py │ ├── admin.py │ ├── api.py │ ├── asgi.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── serializers.py │ ├── settings.py │ ├── tests │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── factories.py │ │ ├── fixtures.py │ │ └── test_api.py │ ├── urls.py │ └── wsgi.py ├── manage.py ├── pyproject.toml └── uv.lock ├── biome.json ├── docker-compose.yaml └── frontend ├── Dockerfile ├── apps └── web │ ├── actions │ ├── change-password-action.ts │ ├── delete-account-action.ts │ ├── profile-action.ts │ └── register-action.ts │ ├── app │ ├── (account) │ │ ├── change-password │ │ │ └── page.tsx │ │ ├── delete-account │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── profile │ │ │ └── page.tsx │ ├── (auth) │ │ ├── api │ │ │ └── auth │ │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ ├── layout.tsx │ │ ├── login │ │ │ └── page.tsx │ │ └── register │ │ │ └── page.tsx │ ├── layout.tsx │ └── page.tsx │ ├── components │ ├── forms │ │ ├── change-password-form.tsx │ │ ├── delete-account-form.tsx │ │ ├── login-form.tsx │ │ ├── profile-form.tsx │ │ └── register-form.tsx │ ├── pages-overview.tsx │ └── user-session.tsx │ ├── lib │ ├── api.ts │ ├── auth.ts │ ├── forms.ts │ └── validation.ts │ ├── next-auth.d.ts │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.js │ ├── providers │ └── auth-provider.tsx │ ├── tailwind.config.ts │ └── tsconfig.json ├── biome.json ├── package.json ├── packages ├── types │ ├── api │ │ ├── Api.ts │ │ ├── ApiClient.ts │ │ ├── core │ │ │ ├── ApiError.ts │ │ │ ├── ApiRequestOptions.ts │ │ │ ├── ApiResult.ts │ │ │ ├── BaseHttpRequest.ts │ │ │ ├── CancelablePromise.ts │ │ │ ├── FetchHttpRequest.ts │ │ │ ├── OpenAPI.ts │ │ │ └── request.ts │ │ ├── index.ts │ │ ├── models │ │ │ ├── PatchedUserCurrent.ts │ │ │ ├── TokenObtainPair.ts │ │ │ ├── TokenRefresh.ts │ │ │ ├── UserChangePassword.ts │ │ │ ├── UserChangePasswordError.ts │ │ │ ├── UserCreate.ts │ │ │ ├── UserCreateError.ts │ │ │ ├── UserCurrent.ts │ │ │ └── UserCurrentError.ts │ │ └── services │ │ │ ├── SchemaService.ts │ │ │ ├── TokenService.ts │ │ │ └── UsersService.ts │ └── package.json └── ui │ ├── components.json │ ├── forms │ ├── form-footer.tsx │ ├── form-header.tsx │ ├── submit-field.tsx │ └── text-field.tsx │ ├── lib │ └── utils.ts │ ├── messages │ ├── error-message.tsx │ └── success-message.tsx │ ├── package.json │ ├── postcss.config.js │ ├── styles │ └── globals.css │ ├── tailwind.config.ts │ └── tsconfig.json ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.devcontainer/backend/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "service": "api", 3 | "name": "Backend", 4 | "dockerComposeFile": ["../../docker-compose.yaml"], 5 | "workspaceFolder": "/app", 6 | "forwardPorts": [8000], 7 | "customizations": { 8 | "vscode": { 9 | "settings": { 10 | "editor.formatOnSave": true, 11 | "[python]": { 12 | "editor.defaultFormatter": "charliermarsh.ruff" 13 | } 14 | }, 15 | "extensions": [ 16 | "ms-python.python", 17 | "ms-python.vscode-pylance", 18 | "batisteo.vscode-django", 19 | "tamasfe.even-better-toml", 20 | "charliermarsh.ruff" 21 | ] 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.devcontainer/frontend/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "service": "web", 3 | "name": "Frontend", 4 | "dockerComposeFile": ["../../docker-compose.yaml"], 5 | "workspaceFolder": "/app", 6 | "forwardPorts": [3000], 7 | "features": { 8 | "ghcr.io/devcontainers/features/node:1": { 9 | "version": "latest" 10 | } 11 | }, 12 | "customizations": { 13 | "vscode": { 14 | "settings": { 15 | "editor.formatOnSave": true, 16 | "editor.codeActionsOnSave": { 17 | "quickfix.biome": "explicit", 18 | "source.organizeImports.biome": "explicit" 19 | }, 20 | "[javascript]": { 21 | "editor.defaultFormatter": "biomejs.biome" 22 | }, 23 | "[typescript]": { 24 | "editor.defaultFormatter": "biomejs.biome" 25 | } 26 | }, 27 | "extensions": ["biomejs.biome", "bradlc.vscode-tailwindcss"] 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.env.backend.template: -------------------------------------------------------------------------------- 1 | DEBUG= 2 | SECRET_KEY= 3 | -------------------------------------------------------------------------------- /.env.frontend.template: -------------------------------------------------------------------------------- 1 | API_URL=http://api:8000 2 | NEXTAUTH_URL=http://localhost:3000/api/auth 3 | NEXTAUTH_SECRET= 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout code 10 | uses: actions/checkout@v4 11 | 12 | - name: Set up Python 13 | uses: actions/setup-python@v5 14 | with: 15 | python-version: '3.13' 16 | 17 | - name: Install pre-commit 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install pre-commit 21 | 22 | - name: Run pre-commit 23 | run: pre-commit run --all-files 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | services: 9 | postgres: 10 | image: postgres 11 | ports: 12 | - 5432:5432 13 | env: 14 | POSTGRES_DB: postgres 15 | POSTGRES_USER: postgres 16 | POSTGRES_PASSWORD: postgres 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - name: Set up Python 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: '3.13' 25 | 26 | - name: Install Uv 27 | run: pip install uv 28 | 29 | - name: Run tests 30 | env: 31 | DATABASE_HOST: localhost 32 | DATABASE_USER: postgres 33 | DATABASE_PASSWORD: postgres 34 | DATABASE_NAME: postgres 35 | run: cd backend && uv run -- pytest . 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | node_modules 4 | .idea 5 | requirements.txt 6 | TODO.md 7 | .env.backend 8 | .env.frontend 9 | data 10 | .pnp 11 | .pnp.js 12 | .pnpm-store 13 | coverage 14 | .next/ 15 | out 16 | build 17 | *.pem 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | .env*.local 22 | .vercel 23 | *.tsbuildinfo 24 | next-env.d.ts 25 | .ruff_cache 26 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-case-conflict 6 | - id: check-json 7 | - id: check-merge-conflict 8 | - id: check-symlinks 9 | - id: check-toml 10 | - id: end-of-file-fixer 11 | - id: trailing-whitespace 12 | - repo: https://github.com/astral-sh/ruff-pre-commit 13 | rev: v0.11.9 14 | hooks: 15 | - id: ruff 16 | args: 17 | - --fix 18 | - id: ruff-format 19 | - repo: https://github.com/compilerla/conventional-pre-commit 20 | rev: v4.2.0 21 | hooks: 22 | - id: conventional-pre-commit 23 | stages: [commit-msg] 24 | args: [] 25 | - repo: https://github.com/biomejs/pre-commit 26 | rev: v2.0.0-beta.3 27 | hooks: 28 | - id: biome-check 29 | additional_dependencies: 30 | - "@biomejs/biome@1.9.4" 31 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensions": [ 3 | "biomejs.biome", 4 | "bradlc.vscode-tailwindcss", 5 | "ms-python.python", 6 | "ms-python.vscode-pylance", 7 | "batisteo.vscode-django", 8 | "tamasfe.even-better-toml", 9 | "charliermarsh.ruff" 10 | ], 11 | "editor.formatOnSave": true, 12 | "editor.codeActionsOnSave": { 13 | "quickfix.biome": "explicit", 14 | "source.organizeImports.biome": "explicit" 15 | }, 16 | "[javascript]": { 17 | "editor.defaultFormatter": "biomejs.biome" 18 | }, 19 | "[typescript]": { 20 | "editor.defaultFormatter": "biomejs.biome" 21 | }, 22 | "[python]": { 23 | "editor.defaultFormatter": "charliermarsh.ruff" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Update OpenAPI schema", 6 | "type": "shell", 7 | "command": "docker compose exec web pnpm openapi:generate", 8 | "isBackground": true 9 | }, 10 | { 11 | "label": "Create superuser", 12 | "type": "shell", 13 | "command": "docker compose exec api uv run -- python manage.py createsuperuser", 14 | "isBackground": false 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Unfold Admin 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Turbo - Django & Next.js boilerplate 2 | 3 | Turbo is a simple bootstrap template for Django and Next.js, combining both frameworks under one monorepository, including best practices. 4 | 5 | ## Features 6 | 7 | - **Microsites**: supports several front ends connected to API backend 8 | - **API typesafety**: exported types from backend stored in shared front end package 9 | - **Server actions**: handling form submissions in server part of Next project 10 | - **Tailwind CSS**: built-in support for all front end packages and sites 11 | - **Docker Compose**: start both front end and backend by running `docker compose up` 12 | - **Auth system**: incorporated user authentication based on JWT tokens 13 | - **Profile management**: update profile information from the front end 14 | - **Registrations**: creation of new user accounts (activation not included) 15 | - **Admin theme**: Unfold admin theme with user & group management 16 | - **Custom user model**: extended default Django user model 17 | - **Visual Studio Code**: project already constains VS Code containers and tasks 18 | 19 | ## Table of contents 20 | 21 | - [Quickstart](#quickstart) 22 | - [Environment files configuration](#environment-files-configuration) 23 | - [Running docker compose](#running-docker-compose) 24 | - [Included dependencies](#included-dependencies) 25 | - [Backend dependencies](#backend-dependencies) 26 | - [Front end dependencies](#front-end-dependencies) 27 | - [Front end project structure](#front-end-project-structure) 28 | - [Adding microsite to docker-compose.yaml](#adding-microsite-to-docker-composeyaml) 29 | - [Authentication](#authentication) 30 | - [Configuring env variables](#configuring-env-variables) 31 | - [User accounts on the backend](#user-accounts-on-the-backend) 32 | - [Authenticated paths on frontend](#authenticated-paths-on-frontend) 33 | - [API calls to backend](#api-calls-to-backend) 34 | - [API Client](#api-client) 35 | - [Updating OpenAPI schema](#updating-openapi-schema) 36 | - [Swagger](#swagger) 37 | - [Client side requests](#client-side-requests) 38 | - [Test suite](#test-suite) 39 | - [Developing in VS Code](#developing-in-vs-code) 40 | 41 | ## Quickstart 42 | 43 | To start using Turbo, it is needed to clone the repository to your local machine and then run `docker compose`, which will take care about the installation process. The only prerequisite for starting Turbo template is to have `docker compose` installed and preconfiguring files with environment variables. 44 | 45 | ```bash 46 | git clone https://github.com/unfoldadmin/turbo.git 47 | cd turbo 48 | ``` 49 | 50 | ### Environment files configuration 51 | 52 | Before you can run `docker compose up`, you have to set up two files with environment variables. Both files are loaded via `docker compose` and variables are available within docker containers. 53 | 54 | ```bash 55 | cp .env.backend.template .env.backend # set SECRET_KEY and DEBUG=1 for debug mode on 56 | cp .env.frontend.template .env.frontend # set NEXTAUTH_SECRET to a value "openssl rand -base64 32" 57 | ``` 58 | 59 | For more advanced environment variables configuration for the front end, it is recommended to read official [Next.js documentation](https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables) about environment variables where it is possible to configure specific variables for each microsite. 60 | 61 | On the backend it is possible to use third party libraries for loading environment variables. In case that loading variables through `os.environ` is not fulfilling the requriements, we recommend using [django-environ](https://github.com/joke2k/django-environ) application. 62 | 63 | ### Running docker compose 64 | 65 | ```bash 66 | docker compose up 67 | ``` 68 | 69 | After successful installation, it will be possible to access both front end (http://localhost:3000) and backend (http://localhost:8000) part of the system from the browsers. 70 | 71 | **NOTE**: Don't forget to change database credentials in docker-compose.yaml and in .env.backend by configuring `DATABASE_PASSWORD`. 72 | 73 | ## Included dependencies 74 | 75 | The general rule when it comes to dependencies is to have minimum of third party applications or plugins to avoid future problems updating the project and keep the maintenance of applications is minimal. 76 | 77 | ### Backend dependencies 78 | 79 | For dependency management in Django application we are using `uv`. When starting the project through the `docker compose` command, it is checked for new dependencies as well. In the case they are not installed, docker will install them before running development server. 80 | 81 | - **[djangorestframework](https://github.com/encode/django-rest-framework)** - REST API support 82 | - **[djangorestframework-simplejwt](https://github.com/jazzband/djangorestframework-simplejwt)** - JWT auth for REST API 83 | - **[drf-spectacular](https://github.com/tfranzel/drf-spectacular)** - OpenAPI schema generator 84 | - **[django-unfold](https://github.com/unfoldadmin/django-unfold)** - Admin theme for Django admin panel 85 | 86 | Below, you can find a command to install new dependency into backend project. 87 | 88 | ```bash 89 | docker compose exec api uv add djangorestframework 90 | ``` 91 | 92 | ### Front end dependencies 93 | 94 | For the frontend project, it is bit more complicated to maintain front end dependencies than in backend part. Dependencies, can be split into two parts. First part are general dependencies available for all projects under packages and apps folders. The second part are dependencies, which are project specific. 95 | 96 | - **[next-auth](https://github.com/nextauthjs/next-auth)** - Next.js authentication 97 | - **[react-hook-form](https://github.com/react-hook-form/react-hook-form)** - Handling of React forms 98 | - **[tailwind-merge](https://github.com/dcastil/tailwind-merge)** - Tailwind CSS class names helper 99 | - **[zod](https://github.com/colinhacks/zod)** - Schema validation 100 | 101 | To install a global dependency for all packages and apps, use `-w` parameter. In case of development package, add `-D` argument to install it into development dependencies. 102 | 103 | ```bash 104 | docker compose exec web pnpm add react-hook-form -w 105 | ``` 106 | 107 | To install a dependency for specific app or package, use `--filter` to specify particular package. 108 | 109 | ```bash 110 | docker compose exec web pnpm --filter web add react-hook-form 111 | ``` 112 | 113 | ## Front end project structure 114 | 115 | Project structure on the front end, it is quite different from the directory hierarchy in the backend. Turbo counts with an option that front end have multiple front ends available on various domains or ports. 116 | 117 | ```text 118 | frontend 119 | | - apps // available sites 120 | | - web // available next.js project 121 | | - packages // shared packages between sites 122 | | - types // exported types from backend - api 123 | | - ui // general ui components 124 | ``` 125 | 126 | The general rule here is, if you want to have some shared code, create new package under packages/ folder. After adding new package and making it available for your website, it is needed to install the new package into website project by running a command below. 127 | 128 | ```bash 129 | docker compose exec web pnpm --filter web add @frontend/ui 130 | ``` 131 | 132 | ### Adding microsite to docker-compose.yaml 133 | 134 | If you want to have new website facing customers, create new project under apps/ directory. Keep in mind that `docker-compose.yaml` file must be adjusted to start a new project with appropriate new port. 135 | 136 | ```yaml 137 | new_microsite: 138 | command: bash -c "pnpm install -r && pnpm --filter new_microsite dev" 139 | build: 140 | context: frontend # Dockerfile can be same 141 | volumes: 142 | - ./frontend:/app 143 | expose: 144 | - "3001" # different port 145 | ports: 146 | - "3001:3001" # different port 147 | env_file: 148 | - .env.frontend 149 | depends_on: 150 | - api 151 | ``` 152 | 153 | ## Authentication 154 | 155 | For the authentication, Turbo uses **django-simplejwt** and **next-auth** package to provide simple REST based JWT authentication. On the backend, there is no configuraton related to django-simplejwt so everything is set to default values. 156 | 157 | On the front end, next-auth is used to provide credentials authentication. The most important file on the front end related to authentication is `frontend/web/lib/auth.ts` which is containing whole business logic behind authentication. 158 | 159 | ### Configuring env variables 160 | 161 | Before starting using authentication, it is crucial to configure environment variable `NEXTAUTH_SECRET` in .env.frontend file. You can set the value to the output of the command below. 162 | 163 | ```bash 164 | openssl rand -base64 32 165 | ``` 166 | 167 | ### User accounts on the backend 168 | 169 | There are two ways how to create new user account in the backend. First option is to run managed command responsible for creating superuser. It is more or less required, if you want to have an access to the Django admin. After running the command below, it will be possible to log in on the front end part of the application. 170 | 171 | ```bash 172 | docker compose exec api uv run -- python manage.py createsuperuser 173 | ``` 174 | 175 | The second option how to create new user account is to register it on the front end. Turbo provides simple registration form. After account registration, it will be not possible to log in because account is inactive. Superuser needs to access Django admin and activate an account. This is a default behavior provided by Turbo, implementation of special way of account activation is currently out the scope of the project. 176 | 177 | ### Authenticated paths on frontend 178 | 179 | To ensure path is only for authenticated users, it is possible to use `getServerSession` to check the status of user. 180 | 181 | This function accepts an argument with authentication options, which can be imported from `@/lib/auth` and contains credentials authentication business logic. 182 | 183 | ```tsx 184 | import { getServerSession } from "next-auth"; 185 | import { redirect } from "next/navigation"; 186 | import { authOptions } from "@/lib/auth"; 187 | 188 | const SomePageForAuthenticatedUsers = async () => { 189 | const session = await getServerSession(authOptions); 190 | 191 | if (session === null) { 192 | return redirect("/"); 193 | } 194 | 195 | return <>content; 196 | }; 197 | ``` 198 | 199 | To require authenticated user account on multiple pages, similar business logic can be applied in `layouts.tsx`. 200 | 201 | ```tsx 202 | import { redirect } from "next/navigation"; 203 | import { getServerSession } from "next-auth"; 204 | import { authOptions } from "@/lib/auth"; 205 | 206 | const AuthenticatedLayout = async ({ 207 | children, 208 | }: { 209 | children: React.ReactNode; 210 | }) => { 211 | const session = await getServerSession(authOptions); 212 | 213 | if (session === null) { 214 | return redirect("/"); 215 | } 216 | 217 | return <>{children}; 218 | }; 219 | 220 | export default AuthenticatedLayout; 221 | ``` 222 | 223 | ## API calls to backend 224 | 225 | Currently Turbo implements Next.js server actions in folder `frontend/apps/web/actions/` responsible for communication with the backend. When the server action is hit from the client, it fetches required data from Django API backend. 226 | 227 | ### API Client 228 | 229 | The query between server action and Django backend is handled by using an API client generated by `openapi-typescript-codegen` package. In Turbo, there is a function `getApiClient` available in `frontend/apps/web/lib/api.ts` which already implements default options and authentication tokens. 230 | 231 | ### Updating OpenAPI schema 232 | 233 | After changes on the backend, for example adding new fields into serializers, it is required to update typescript schema on the frontend. The schema can be updated by running command below. In VS Code, there is prepared task which will update definition. 234 | 235 | ```bash 236 | docker compose exec web pnpm openapi:generate 237 | ``` 238 | 239 | ### Swagger 240 | 241 | By default, Turbo includes Swagger for API schema which is available here `http://localhost:8000/api/schema/swagger-ui/`. Swagger can be disabled by editing `urls.py` and removing `SpectacularSwaggerView`. 242 | 243 | ### Client side requests 244 | 245 | At the moment, Turbo does not contain any examples of client side requests towards the backend. All the requests are handled by server actions. For client side requests, it is recommended to use [react-query](https://github.com/TanStack/query). 246 | 247 | ## Test suite 248 | 249 | Project contains test suite for backend part. For testing it was used library called [pytest](https://docs.pytest.org/en/latest/) along with some additinal libraries extending functionality of pytest: 250 | 251 | - [pytest-django](https://pytest-django.readthedocs.io/en/latest/) - for testing django applications 252 | - [pytest-factoryboy](https://pytest-factoryboy.readthedocs.io/en/latest/) - for creating test data 253 | 254 | All these libraries mentioned above are already preconfigured in `backend/api/tests` directory. 255 | 256 | - `conftest.py` - for configuring pytest 257 | - `factories.py` - for generating reusable test objects using factory_boy, which creates model instances with default values that can be customized as needed 258 | - `fixtures.py` - for creating pytest fixtures that provide test data or resources that can be shared and reused across multiple tests 259 | 260 | To run tests, use the command below which will collect all the tests available in backend/api/tests folder: 261 | 262 | ```bash 263 | docker compose exec api uv run -- pytest . 264 | ``` 265 | 266 | Tu run tests available only in one specific file run: 267 | 268 | ```bash 269 | docker compose exec api uv run -- pytest api/tests/test_api.py 270 | ``` 271 | 272 | To run one specific test, use the command below: 273 | 274 | ```bash 275 | docker compose exec api uv run -- pytest api/tests/test_api.py -k "test_api_users_me_authorized" 276 | ``` 277 | 278 | ## Developing in VS Code 279 | 280 | The project contains configuration files for devcontainers so it is possible to directly work inside the container within VS Code. When the project opens in the VS Code the popup will appear to reopen the project in container. An action **Dev Containers: Reopen in Container** is available as well. Click on the reopen button and select the container which you want to work on. When you want to switch from the frontend to the backend project run **Dev Containers: Switch container** action. In case you are done and you want to work in the parent folder run **Dev Containers: Reopen Folder Locally** action 281 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13-slim-bookworm 2 | 3 | ENV PYTHONUNBUFFERED=1 4 | ENV UV_PROJECT_ENVIRONMENT=/.venv 5 | 6 | WORKDIR /app 7 | 8 | COPY pyproject.toml uv.lock ./ 9 | 10 | RUN pip install uv && \ 11 | uv venv && \ 12 | uv sync 13 | 14 | EXPOSE 8000 15 | -------------------------------------------------------------------------------- /backend/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unfoldadmin/turbo/5b1b629aca5297658a3632130d881f5ec97c34da/backend/api/__init__.py -------------------------------------------------------------------------------- /backend/api/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin 3 | from django.contrib.auth.admin import UserAdmin as BaseUserAdmin 4 | from django.contrib.auth.models import Group 5 | from unfold.admin import ModelAdmin 6 | from unfold.forms import AdminPasswordChangeForm, UserChangeForm, UserCreationForm 7 | 8 | from .models import User 9 | 10 | admin.site.unregister(Group) 11 | 12 | 13 | @admin.register(User) 14 | class UserAdmin(BaseUserAdmin, ModelAdmin): 15 | form = UserChangeForm 16 | add_form = UserCreationForm 17 | change_password_form = AdminPasswordChangeForm 18 | 19 | 20 | @admin.register(Group) 21 | class GroupAdmin(BaseGroupAdmin, ModelAdmin): 22 | pass 23 | -------------------------------------------------------------------------------- /backend/api/api.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from drf_spectacular.utils import extend_schema 3 | from rest_framework import mixins, status, viewsets 4 | from rest_framework.decorators import action 5 | from rest_framework.permissions import AllowAny, IsAuthenticated 6 | from rest_framework.response import Response 7 | 8 | from .serializers import ( 9 | UserChangePasswordErrorSerializer, 10 | UserChangePasswordSerializer, 11 | UserCreateErrorSerializer, 12 | UserCreateSerializer, 13 | UserCurrentErrorSerializer, 14 | UserCurrentSerializer, 15 | ) 16 | 17 | User = get_user_model() 18 | 19 | 20 | class UserViewSet( 21 | mixins.CreateModelMixin, 22 | viewsets.GenericViewSet, 23 | ): 24 | queryset = User.objects.all() 25 | serializer_class = UserCurrentSerializer 26 | permission_classes = [IsAuthenticated] 27 | 28 | def get_queryset(self): 29 | return self.queryset.filter(pk=self.request.user.pk) 30 | 31 | def get_permissions(self): 32 | if self.action == "create": 33 | return [AllowAny()] 34 | 35 | return super().get_permissions() 36 | 37 | def get_serializer_class(self): 38 | if self.action == "create": 39 | return UserCreateSerializer 40 | elif self.action == "me": 41 | return UserCurrentSerializer 42 | elif self.action == "change_password": 43 | return UserChangePasswordSerializer 44 | 45 | return super().get_serializer_class() 46 | 47 | @extend_schema( 48 | responses={ 49 | 200: UserCreateSerializer, 50 | 400: UserCreateErrorSerializer, 51 | } 52 | ) 53 | def create(self, request, *args, **kwargs): 54 | return super().create(request, *args, **kwargs) 55 | 56 | @extend_schema( 57 | responses={ 58 | 200: UserCurrentSerializer, 59 | 400: UserCurrentErrorSerializer, 60 | } 61 | ) 62 | @action(["get", "put", "patch"], detail=False) 63 | def me(self, request, *args, **kwargs): 64 | if request.method == "GET": 65 | serializer = self.get_serializer(self.request.user) 66 | return Response(serializer.data) 67 | elif request.method == "PUT": 68 | serializer = self.get_serializer( 69 | self.request.user, data=request.data, partial=False 70 | ) 71 | serializer.is_valid(raise_exception=True) 72 | serializer.save() 73 | return Response(serializer.data) 74 | elif request.method == "PATCH": 75 | serializer = self.get_serializer( 76 | self.request.user, data=request.data, partial=True 77 | ) 78 | serializer.is_valid(raise_exception=True) 79 | serializer.save() 80 | return Response(serializer.data) 81 | 82 | @extend_schema( 83 | responses={ 84 | 204: None, 85 | 400: UserChangePasswordErrorSerializer, 86 | } 87 | ) 88 | @action(["post"], url_path="change-password", detail=False) 89 | def change_password(self, request, *args, **kwargs): 90 | serializer = self.get_serializer(data=request.data) 91 | serializer.is_valid(raise_exception=True) 92 | 93 | self.request.user.set_password(serializer.data["password_new"]) 94 | self.request.user.save() 95 | 96 | return Response(status=status.HTTP_204_NO_CONTENT) 97 | 98 | @action(["delete"], url_path="delete-account", detail=False) 99 | def delete_account(self, request, *args, **kwargs): 100 | self.request.user.delete() 101 | return Response(status=status.HTTP_204_NO_CONTENT) 102 | -------------------------------------------------------------------------------- /backend/api/asgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.asgi import get_asgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "api.settings") 6 | 7 | application = get_asgi_application() 8 | -------------------------------------------------------------------------------- /backend/api/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | import django.contrib.auth.models 2 | import django.contrib.auth.validators 3 | import django.utils.timezone 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [ 11 | ("auth", "0012_alter_user_first_name_max_length"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="User", 17 | fields=[ 18 | ( 19 | "id", 20 | models.BigAutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("password", models.CharField(max_length=128, verbose_name="password")), 28 | ( 29 | "last_login", 30 | models.DateTimeField( 31 | blank=True, null=True, verbose_name="last login" 32 | ), 33 | ), 34 | ( 35 | "is_superuser", 36 | models.BooleanField( 37 | default=False, 38 | help_text="Designates that this user has all permissions without explicitly assigning them.", 39 | verbose_name="superuser status", 40 | ), 41 | ), 42 | ( 43 | "username", 44 | models.CharField( 45 | error_messages={ 46 | "unique": "A user with that username already exists." 47 | }, 48 | help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", 49 | max_length=150, 50 | unique=True, 51 | validators=[ 52 | django.contrib.auth.validators.UnicodeUsernameValidator() 53 | ], 54 | verbose_name="username", 55 | ), 56 | ), 57 | ( 58 | "first_name", 59 | models.CharField( 60 | blank=True, max_length=150, verbose_name="first name" 61 | ), 62 | ), 63 | ( 64 | "last_name", 65 | models.CharField( 66 | blank=True, max_length=150, verbose_name="last name" 67 | ), 68 | ), 69 | ( 70 | "email", 71 | models.EmailField( 72 | blank=True, max_length=254, verbose_name="email address" 73 | ), 74 | ), 75 | ( 76 | "is_staff", 77 | models.BooleanField( 78 | default=False, 79 | help_text="Designates whether the user can log into this admin site.", 80 | verbose_name="staff status", 81 | ), 82 | ), 83 | ( 84 | "is_active", 85 | models.BooleanField( 86 | default=True, 87 | help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", 88 | verbose_name="active", 89 | ), 90 | ), 91 | ( 92 | "date_joined", 93 | models.DateTimeField( 94 | default=django.utils.timezone.now, verbose_name="date joined" 95 | ), 96 | ), 97 | ( 98 | "created_at", 99 | models.DateTimeField(auto_now_add=True, verbose_name="created at"), 100 | ), 101 | ( 102 | "modified_at", 103 | models.DateTimeField(auto_now=True, verbose_name="modified at"), 104 | ), 105 | ( 106 | "groups", 107 | models.ManyToManyField( 108 | blank=True, 109 | help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", 110 | related_name="user_set", 111 | related_query_name="user", 112 | to="auth.group", 113 | verbose_name="groups", 114 | ), 115 | ), 116 | ( 117 | "user_permissions", 118 | models.ManyToManyField( 119 | blank=True, 120 | help_text="Specific permissions for this user.", 121 | related_name="user_set", 122 | related_query_name="user", 123 | to="auth.permission", 124 | verbose_name="user permissions", 125 | ), 126 | ), 127 | ], 128 | options={ 129 | "verbose_name": "user", 130 | "verbose_name_plural": "users", 131 | "db_table": "users", 132 | }, 133 | managers=[ 134 | ("objects", django.contrib.auth.models.UserManager()), 135 | ], 136 | ), 137 | ] 138 | -------------------------------------------------------------------------------- /backend/api/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unfoldadmin/turbo/5b1b629aca5297658a3632130d881f5ec97c34da/backend/api/migrations/__init__.py -------------------------------------------------------------------------------- /backend/api/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AbstractUser 2 | from django.db import models 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | 6 | class User(AbstractUser): 7 | created_at = models.DateTimeField(_("created at"), auto_now_add=True) 8 | modified_at = models.DateTimeField(_("modified at"), auto_now=True) 9 | 10 | class Meta: 11 | db_table = "users" 12 | verbose_name = _("user") 13 | verbose_name_plural = _("users") 14 | 15 | def __str__(self): 16 | return self.email if self.email else self.username 17 | -------------------------------------------------------------------------------- /backend/api/serializers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.contrib.auth.password_validation import validate_password 3 | from django.core.exceptions import ValidationError 4 | from django.db import transaction 5 | from django.utils.translation import gettext_lazy as _ 6 | from rest_framework import exceptions, serializers 7 | 8 | User = get_user_model() 9 | 10 | 11 | class UserCurrentSerializer(serializers.ModelSerializer): 12 | class Meta: 13 | model = User 14 | fields = ["username", "first_name", "last_name"] 15 | 16 | 17 | class UserCurrentErrorSerializer(serializers.Serializer): 18 | username = serializers.ListSerializer(child=serializers.CharField(), required=False) 19 | first_name = serializers.ListSerializer( 20 | child=serializers.CharField(), required=False 21 | ) 22 | last_name = serializers.ListSerializer( 23 | child=serializers.CharField(), required=False 24 | ) 25 | 26 | 27 | class UserChangePasswordSerializer(serializers.ModelSerializer): 28 | password = serializers.CharField(style={"input_type": "password"}, write_only=True) 29 | password_new = serializers.CharField(style={"input_type": "password"}) 30 | password_retype = serializers.CharField( 31 | style={"input_type": "password"}, write_only=True 32 | ) 33 | 34 | default_error_messages = { 35 | "password_mismatch": _("Current password is not matching"), 36 | "password_invalid": _("Password does not meet all requirements"), 37 | "password_same": _("Both new and current passwords are same"), 38 | } 39 | 40 | class Meta: 41 | model = User 42 | fields = ["password", "password_new", "password_retype"] 43 | 44 | def validate(self, attrs): 45 | request = self.context.get("request", None) 46 | 47 | if not request.user.check_password(attrs["password"]): 48 | raise serializers.ValidationError( 49 | {"password": self.default_error_messages["password_mismatch"]} 50 | ) 51 | 52 | try: 53 | validate_password(attrs["password_new"]) 54 | except ValidationError as e: 55 | raise exceptions.ValidationError({"password_new": list(e.messages)}) from e 56 | 57 | if attrs["password_new"] != attrs["password_retype"]: 58 | raise serializers.ValidationError( 59 | {"password_retype": self.default_error_messages["password_invalid"]} 60 | ) 61 | 62 | if attrs["password_new"] == attrs["password"]: 63 | raise serializers.ValidationError( 64 | {"password_new": self.default_error_messages["password_same"]} 65 | ) 66 | return super().validate(attrs) 67 | 68 | 69 | class UserChangePasswordErrorSerializer(serializers.Serializer): 70 | password = serializers.ListSerializer(child=serializers.CharField(), required=False) 71 | password_new = serializers.ListSerializer( 72 | child=serializers.CharField(), required=False 73 | ) 74 | password_retype = serializers.ListSerializer( 75 | child=serializers.CharField(), required=False 76 | ) 77 | 78 | 79 | class UserCreateSerializer(serializers.ModelSerializer): 80 | password = serializers.CharField(style={"input_type": "password"}, write_only=True) 81 | password_retype = serializers.CharField( 82 | style={"input_type": "password"}, write_only=True 83 | ) 84 | 85 | default_error_messages = { 86 | "password_mismatch": _("Password are not matching."), 87 | "password_invalid": _("Password does not meet all requirements."), 88 | } 89 | 90 | class Meta: 91 | model = User 92 | fields = ["username", "password", "password_retype"] 93 | 94 | def validate(self, attrs): 95 | password_retype = attrs.pop("password_retype") 96 | 97 | try: 98 | validate_password(attrs.get("password")) 99 | except exceptions.ValidationError: 100 | self.fail("password_invalid") 101 | 102 | if attrs["password"] == password_retype: 103 | return attrs 104 | 105 | return self.fail("password_mismatch") 106 | 107 | def create(self, validated_data): 108 | with transaction.atomic(): 109 | user = User.objects.create_user(**validated_data) 110 | 111 | # By default newly registered accounts are inactive. 112 | user.is_active = False 113 | user.save(update_fields=["is_active"]) 114 | 115 | return user 116 | 117 | 118 | class UserCreateErrorSerializer(serializers.Serializer): 119 | username = serializers.ListSerializer(child=serializers.CharField(), required=False) 120 | password = serializers.ListSerializer(child=serializers.CharField(), required=False) 121 | password_retype = serializers.ListSerializer( 122 | child=serializers.CharField(), required=False 123 | ) 124 | -------------------------------------------------------------------------------- /backend/api/settings.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | from pathlib import Path 3 | 4 | from django.core.management.utils import get_random_secret_key 5 | from django.urls import reverse_lazy 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | ###################################################################### 9 | # General 10 | ###################################################################### 11 | BASE_DIR = Path(__file__).resolve().parent.parent 12 | 13 | SECRET_KEY = environ.get("SECRET_KEY", get_random_secret_key()) 14 | 15 | DEBUG = environ.get("DEBUG", "") == "1" 16 | 17 | ALLOWED_HOSTS = ["localhost", "api"] 18 | 19 | WSGI_APPLICATION = "api.wsgi.application" 20 | 21 | ROOT_URLCONF = "api.urls" 22 | 23 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 24 | 25 | ###################################################################### 26 | # Apps 27 | ###################################################################### 28 | INSTALLED_APPS = [ 29 | "unfold", 30 | "django.contrib.admin", 31 | "django.contrib.auth", 32 | "django.contrib.contenttypes", 33 | "django.contrib.sessions", 34 | "django.contrib.messages", 35 | "django.contrib.staticfiles", 36 | "rest_framework", 37 | "rest_framework_simplejwt", 38 | "drf_spectacular", 39 | "api", 40 | ] 41 | 42 | ###################################################################### 43 | # Middleware 44 | ###################################################################### 45 | MIDDLEWARE = [ 46 | "django.middleware.security.SecurityMiddleware", 47 | "django.contrib.sessions.middleware.SessionMiddleware", 48 | "django.middleware.common.CommonMiddleware", 49 | "django.middleware.csrf.CsrfViewMiddleware", 50 | "django.contrib.auth.middleware.AuthenticationMiddleware", 51 | "django.contrib.messages.middleware.MessageMiddleware", 52 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 53 | ] 54 | 55 | ###################################################################### 56 | # Templates 57 | ###################################################################### 58 | TEMPLATES = [ 59 | { 60 | "BACKEND": "django.template.backends.django.DjangoTemplates", 61 | "DIRS": [], 62 | "APP_DIRS": True, 63 | "OPTIONS": { 64 | "context_processors": [ 65 | "django.template.context_processors.debug", 66 | "django.template.context_processors.request", 67 | "django.contrib.auth.context_processors.auth", 68 | "django.contrib.messages.context_processors.messages", 69 | ], 70 | }, 71 | }, 72 | ] 73 | 74 | ###################################################################### 75 | # Database 76 | ###################################################################### 77 | DATABASES = { 78 | "default": { 79 | "ENGINE": "django.db.backends.postgresql", 80 | "USER": environ.get("DATABASE_USER", "postgres"), 81 | "PASSWORD": environ.get("DATABASE_PASSWORD", "change-password"), 82 | "NAME": environ.get("DATABASE_NAME", "db"), 83 | "HOST": environ.get("DATABASE_HOST", "db"), 84 | "PORT": "5432", 85 | "TEST": { 86 | "NAME": "test", 87 | }, 88 | } 89 | } 90 | 91 | ###################################################################### 92 | # Authentication 93 | ###################################################################### 94 | AUTH_USER_MODEL = "api.User" 95 | 96 | AUTH_PASSWORD_VALIDATORS = [ 97 | { 98 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 99 | }, 100 | { 101 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 102 | }, 103 | { 104 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 105 | }, 106 | { 107 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 108 | }, 109 | ] 110 | 111 | ###################################################################### 112 | # Internationalization 113 | ###################################################################### 114 | LANGUAGE_CODE = "en-us" 115 | 116 | TIME_ZONE = "UTC" 117 | 118 | USE_I18N = True 119 | 120 | USE_TZ = True 121 | 122 | ###################################################################### 123 | # Staticfiles 124 | ###################################################################### 125 | STATIC_URL = "static/" 126 | 127 | ###################################################################### 128 | # Rest Framework 129 | ###################################################################### 130 | REST_FRAMEWORK = { 131 | "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", 132 | "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", 133 | "PAGE_SIZE": 10, 134 | "DEFAULT_PERMISSION_CLASSES": [ 135 | "rest_framework.permissions.IsAuthenticated", 136 | ], 137 | "DEFAULT_AUTHENTICATION_CLASSES": [ 138 | "rest_framework_simplejwt.authentication.JWTAuthentication", 139 | "rest_framework.authentication.SessionAuthentication", 140 | ], 141 | } 142 | 143 | ###################################################################### 144 | # Unfold 145 | ###################################################################### 146 | UNFOLD = { 147 | "SITE_HEADER": _("Turbo Admin"), 148 | "SITE_TITLE": _("Turbo Admin"), 149 | "SIDEBAR": { 150 | "show_search": True, 151 | "show_all_applications": True, 152 | "navigation": [ 153 | { 154 | "title": _("Navigation"), 155 | "separator": False, 156 | "items": [ 157 | { 158 | "title": _("Users"), 159 | "icon": "person", 160 | "link": reverse_lazy("admin:api_user_changelist"), 161 | }, 162 | { 163 | "title": _("Groups"), 164 | "icon": "label", 165 | "link": reverse_lazy("admin:auth_group_changelist"), 166 | }, 167 | ], 168 | }, 169 | ], 170 | }, 171 | } 172 | -------------------------------------------------------------------------------- /backend/api/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unfoldadmin/turbo/5b1b629aca5297658a3632130d881f5ec97c34da/backend/api/tests/__init__.py -------------------------------------------------------------------------------- /backend/api/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pytest_factoryboy import register 2 | 3 | from api.tests.factories import UserFactory 4 | from api.tests.fixtures import * # noqa: F403 5 | 6 | register(UserFactory) 7 | -------------------------------------------------------------------------------- /backend/api/tests/factories.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from factory.django import DjangoModelFactory 3 | 4 | 5 | class UserFactory(DjangoModelFactory): 6 | username = "sample@example.com" 7 | email = "sample@example.com" 8 | 9 | class Meta: 10 | model = get_user_model() 11 | -------------------------------------------------------------------------------- /backend/api/tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from rest_framework.test import APIClient 3 | 4 | 5 | @pytest.fixture 6 | def api_client(): 7 | return APIClient() 8 | 9 | 10 | @pytest.fixture 11 | def regular_user(user_factory): 12 | return user_factory.create(is_active=False) 13 | -------------------------------------------------------------------------------- /backend/api/tests/test_api.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.urls import reverse 3 | from rest_framework import status 4 | 5 | 6 | @pytest.mark.django_db 7 | def test_api_users_me_unauthorized(client): 8 | response = client.get(reverse("api-users-me")) 9 | assert response.status_code == status.HTTP_401_UNAUTHORIZED 10 | 11 | 12 | @pytest.mark.django_db 13 | def test_api_users_me_authorized(api_client, regular_user): 14 | api_client.force_authenticate(user=regular_user) 15 | response = api_client.get(reverse("api-users-me")) 16 | assert response.status_code == status.HTTP_200_OK 17 | -------------------------------------------------------------------------------- /backend/api/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView 4 | from rest_framework import routers 5 | from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView 6 | 7 | from .api import UserViewSet 8 | 9 | router = routers.DefaultRouter() 10 | router.register("users", UserViewSet, basename="api-users") 11 | 12 | urlpatterns = [ 13 | path( 14 | "api/schema/swagger-ui/", 15 | SpectacularSwaggerView.as_view(url_name="schema"), 16 | ), 17 | path("api/schema/", SpectacularAPIView.as_view(), name="schema"), 18 | path("api/", include(router.urls)), 19 | path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), 20 | path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), 21 | path("admin/", admin.site.urls), 22 | ] 23 | -------------------------------------------------------------------------------- /backend/api/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "api.settings") 6 | 7 | application = get_wsgi_application() 8 | -------------------------------------------------------------------------------- /backend/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | 4 | import os 5 | import sys 6 | 7 | 8 | def main(): 9 | """Run administrative tasks.""" 10 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "api.settings") 11 | try: 12 | from django.core.management import execute_from_command_line 13 | except ImportError as exc: 14 | raise ImportError( 15 | "Couldn't import Django. Are you sure it's installed and " 16 | "available on your PYTHONPATH environment variable? Did you " 17 | "forget to activate a virtual environment?" 18 | ) from exc 19 | execute_from_command_line(sys.argv) 20 | 21 | 22 | if __name__ == "__main__": 23 | main() 24 | -------------------------------------------------------------------------------- /backend/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "api" 3 | requires-python = ">=3.13" 4 | version = "0.1.0" 5 | dependencies = [ 6 | "django>=5.1", 7 | "psycopg[binary]>=3.2", 8 | "djangorestframework>=3.15", 9 | "djangorestframework-simplejwt>=5.3", 10 | "drf-spectacular>=0.28", 11 | "django-unfold>=0.43.0", 12 | ] 13 | 14 | [dependency-groups] 15 | dev = [ 16 | "pytest>=8.3.4", 17 | "pytest-django>=4.9.0", 18 | "pytest-factoryboy>=2.7.0", 19 | ] 20 | 21 | [tool.ruff] 22 | fix = true 23 | line-length = 88 24 | 25 | [tool.ruff.lint] 26 | select = [ 27 | "E", # pycodestyle errors 28 | "W", # pycodestyle warnings 29 | "F", # pyflakes 30 | "I", # isort 31 | "C", # flake8-comprehensions 32 | "B", # flake8-bugbear 33 | "UP", # pyupgrade 34 | ] 35 | ignore = [ 36 | "E501", # line too long, handled by black 37 | "B008", # do not perform function calls in argument defaults 38 | "C901", # too complex 39 | ] 40 | 41 | [tool.pytest.ini_options] 42 | filterwarnings = "ignore" 43 | addopts = "--strict-config --strict-markers --ds=api.settings" 44 | -------------------------------------------------------------------------------- /backend/uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.13" 3 | 4 | [[package]] 5 | name = "api" 6 | version = "0.1.0" 7 | source = { virtual = "." } 8 | dependencies = [ 9 | { name = "django" }, 10 | { name = "django-unfold" }, 11 | { name = "djangorestframework" }, 12 | { name = "djangorestframework-simplejwt" }, 13 | { name = "drf-spectacular" }, 14 | { name = "psycopg", extra = ["binary"] }, 15 | ] 16 | 17 | [package.dev-dependencies] 18 | dev = [ 19 | { name = "pytest" }, 20 | { name = "pytest-django" }, 21 | { name = "pytest-factoryboy" }, 22 | ] 23 | 24 | [package.metadata] 25 | requires-dist = [ 26 | { name = "django", specifier = ">=5.1" }, 27 | { name = "django-unfold", specifier = ">=0.43.0" }, 28 | { name = "djangorestframework", specifier = ">=3.15" }, 29 | { name = "djangorestframework-simplejwt", specifier = ">=5.3" }, 30 | { name = "drf-spectacular", specifier = ">=0.28" }, 31 | { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, 32 | ] 33 | 34 | [package.metadata.requires-dev] 35 | dev = [ 36 | { name = "pytest", specifier = ">=8.3.4" }, 37 | { name = "pytest-django", specifier = ">=4.9.0" }, 38 | { name = "pytest-factoryboy", specifier = ">=2.7.0" }, 39 | ] 40 | 41 | [[package]] 42 | name = "asgiref" 43 | version = "3.8.1" 44 | source = { registry = "https://pypi.org/simple" } 45 | sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186 } 46 | wheels = [ 47 | { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 }, 48 | ] 49 | 50 | [[package]] 51 | name = "attrs" 52 | version = "24.3.0" 53 | source = { registry = "https://pypi.org/simple" } 54 | sdist = { url = "https://files.pythonhosted.org/packages/48/c8/6260f8ccc11f0917360fc0da435c5c9c7504e3db174d5a12a1494887b045/attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff", size = 805984 } 55 | wheels = [ 56 | { url = "https://files.pythonhosted.org/packages/89/aa/ab0f7891a01eeb2d2e338ae8fecbe57fcebea1a24dbb64d45801bfab481d/attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308", size = 63397 }, 57 | ] 58 | 59 | [[package]] 60 | name = "colorama" 61 | version = "0.4.6" 62 | source = { registry = "https://pypi.org/simple" } 63 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 64 | wheels = [ 65 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 66 | ] 67 | 68 | [[package]] 69 | name = "django" 70 | version = "5.1.4" 71 | source = { registry = "https://pypi.org/simple" } 72 | dependencies = [ 73 | { name = "asgiref" }, 74 | { name = "sqlparse" }, 75 | { name = "tzdata", marker = "sys_platform == 'win32'" }, 76 | ] 77 | sdist = { url = "https://files.pythonhosted.org/packages/d3/e8/536555596dbb79f6e77418aeb40bdc1758c26725aba31919ba449e6d5e6a/Django-5.1.4.tar.gz", hash = "sha256:de450c09e91879fa5a307f696e57c851955c910a438a35e6b4c895e86bedc82a", size = 10716397 } 78 | wheels = [ 79 | { url = "https://files.pythonhosted.org/packages/58/0b/8a4ab2c02982df4ed41e29f28f189459a7eba37899438e6bea7f39db793b/Django-5.1.4-py3-none-any.whl", hash = "sha256:236e023f021f5ce7dee5779de7b286565fdea5f4ab86bae5338e3f7b69896cf0", size = 8276471 }, 80 | ] 81 | 82 | [[package]] 83 | name = "django-unfold" 84 | version = "0.43.0" 85 | source = { registry = "https://pypi.org/simple" } 86 | dependencies = [ 87 | { name = "django" }, 88 | ] 89 | sdist = { url = "https://files.pythonhosted.org/packages/59/2f/7e3c18f9c91f540bc01edd5ed8763e5d1eedcd2a36470d54c57d8f212de5/django_unfold-0.43.0.tar.gz", hash = "sha256:c1f2e4314b87db2f4d845f6507667404df8e33ea0e8f336d604c5863c9358031", size = 746364 } 90 | wheels = [ 91 | { url = "https://files.pythonhosted.org/packages/d1/e4/97550447f35f2ee66b0ecce35ea70dab7ab525762e8c74231f243c379bcb/django_unfold-0.43.0-py3-none-any.whl", hash = "sha256:e60252393fadd76b6a2c90437512bac92c9a1c5d162d8df3bc13ebd401226181", size = 814453 }, 92 | ] 93 | 94 | [[package]] 95 | name = "djangorestframework" 96 | version = "3.15.2" 97 | source = { registry = "https://pypi.org/simple" } 98 | dependencies = [ 99 | { name = "django" }, 100 | ] 101 | sdist = { url = "https://files.pythonhosted.org/packages/2c/ce/31482eb688bdb4e271027076199e1aa8d02507e530b6d272ab8b4481557c/djangorestframework-3.15.2.tar.gz", hash = "sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad", size = 1067420 } 102 | wheels = [ 103 | { url = "https://files.pythonhosted.org/packages/7c/b6/fa99d8f05eff3a9310286ae84c4059b08c301ae4ab33ae32e46e8ef76491/djangorestframework-3.15.2-py3-none-any.whl", hash = "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20", size = 1071235 }, 104 | ] 105 | 106 | [[package]] 107 | name = "djangorestframework-simplejwt" 108 | version = "5.3.1" 109 | source = { registry = "https://pypi.org/simple" } 110 | dependencies = [ 111 | { name = "django" }, 112 | { name = "djangorestframework" }, 113 | { name = "pyjwt" }, 114 | ] 115 | sdist = { url = "https://files.pythonhosted.org/packages/ac/f3/f2ce06fcd1c53e12b26cc5a3ec9e0acd47eb4be02e1d24de50edee5c5abf/djangorestframework_simplejwt-5.3.1.tar.gz", hash = "sha256:6c4bd37537440bc439564ebf7d6085e74c5411485197073f508ebdfa34bc9fae", size = 94266 } 116 | wheels = [ 117 | { url = "https://files.pythonhosted.org/packages/f2/ab/88f73cf08d2ad3fb9f71b956dceca5680a57f121e5ce9a604f365877d57e/djangorestframework_simplejwt-5.3.1-py3-none-any.whl", hash = "sha256:381bc966aa46913905629d472cd72ad45faa265509764e20ffd440164c88d220", size = 101339 }, 118 | ] 119 | 120 | [[package]] 121 | name = "drf-spectacular" 122 | version = "0.28.0" 123 | source = { registry = "https://pypi.org/simple" } 124 | dependencies = [ 125 | { name = "django" }, 126 | { name = "djangorestframework" }, 127 | { name = "inflection" }, 128 | { name = "jsonschema" }, 129 | { name = "pyyaml" }, 130 | { name = "uritemplate" }, 131 | ] 132 | sdist = { url = "https://files.pythonhosted.org/packages/da/b9/741056455aed00fa51a1df41fad5ad27c8e0d433b6bf490d4e60e2808bc6/drf_spectacular-0.28.0.tar.gz", hash = "sha256:2c778a47a40ab2f5078a7c42e82baba07397bb35b074ae4680721b2805943061", size = 237849 } 133 | wheels = [ 134 | { url = "https://files.pythonhosted.org/packages/fb/66/c2929871393b1515c3767a670ff7d980a6882964a31a4ca2680b30d7212a/drf_spectacular-0.28.0-py3-none-any.whl", hash = "sha256:856e7edf1056e49a4245e87a61e8da4baff46c83dbc25be1da2df77f354c7cb4", size = 103928 }, 135 | ] 136 | 137 | [[package]] 138 | name = "factory-boy" 139 | version = "3.3.1" 140 | source = { registry = "https://pypi.org/simple" } 141 | dependencies = [ 142 | { name = "faker" }, 143 | ] 144 | sdist = { url = "https://files.pythonhosted.org/packages/99/3d/8070dde623341401b1c80156583d4c793058fe250450178218bb6e45526c/factory_boy-3.3.1.tar.gz", hash = "sha256:8317aa5289cdfc45f9cae570feb07a6177316c82e34d14df3c2e1f22f26abef0", size = 163924 } 145 | wheels = [ 146 | { url = "https://files.pythonhosted.org/packages/33/cf/44ec67152f3129d0114c1499dd34f0a0a0faf43d9c2af05bc535746ca482/factory_boy-3.3.1-py2.py3-none-any.whl", hash = "sha256:7b1113c49736e1e9995bc2a18f4dbf2c52cf0f841103517010b1d825712ce3ca", size = 36878 }, 147 | ] 148 | 149 | [[package]] 150 | name = "faker" 151 | version = "33.1.0" 152 | source = { registry = "https://pypi.org/simple" } 153 | dependencies = [ 154 | { name = "python-dateutil" }, 155 | { name = "typing-extensions" }, 156 | ] 157 | sdist = { url = "https://files.pythonhosted.org/packages/1e/9f/012fd6049fc86029951cba5112d32c7ba076c4290d7e8873b0413655b808/faker-33.1.0.tar.gz", hash = "sha256:1c925fc0e86a51fc46648b504078c88d0cd48da1da2595c4e712841cab43a1e4", size = 1850515 } 158 | wheels = [ 159 | { url = "https://files.pythonhosted.org/packages/08/9c/2bba87fbfa42503ddd9653e3546ffc4ed18b14ecab7a07ee86491b886486/Faker-33.1.0-py3-none-any.whl", hash = "sha256:d30c5f0e2796b8970de68978365247657486eb0311c5abe88d0b895b68dff05d", size = 1889127 }, 160 | ] 161 | 162 | [[package]] 163 | name = "inflection" 164 | version = "0.5.1" 165 | source = { registry = "https://pypi.org/simple" } 166 | sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091 } 167 | wheels = [ 168 | { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454 }, 169 | ] 170 | 171 | [[package]] 172 | name = "iniconfig" 173 | version = "2.0.0" 174 | source = { registry = "https://pypi.org/simple" } 175 | sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } 176 | wheels = [ 177 | { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, 178 | ] 179 | 180 | [[package]] 181 | name = "jsonschema" 182 | version = "4.23.0" 183 | source = { registry = "https://pypi.org/simple" } 184 | dependencies = [ 185 | { name = "attrs" }, 186 | { name = "jsonschema-specifications" }, 187 | { name = "referencing" }, 188 | { name = "rpds-py" }, 189 | ] 190 | sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778 } 191 | wheels = [ 192 | { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462 }, 193 | ] 194 | 195 | [[package]] 196 | name = "jsonschema-specifications" 197 | version = "2024.10.1" 198 | source = { registry = "https://pypi.org/simple" } 199 | dependencies = [ 200 | { name = "referencing" }, 201 | ] 202 | sdist = { url = "https://files.pythonhosted.org/packages/10/db/58f950c996c793472e336ff3655b13fbcf1e3b359dcf52dcf3ed3b52c352/jsonschema_specifications-2024.10.1.tar.gz", hash = "sha256:0f38b83639958ce1152d02a7f062902c41c8fd20d558b0c34344292d417ae272", size = 15561 } 203 | wheels = [ 204 | { url = "https://files.pythonhosted.org/packages/d1/0f/8910b19ac0670a0f80ce1008e5e751c4a57e14d2c4c13a482aa6079fa9d6/jsonschema_specifications-2024.10.1-py3-none-any.whl", hash = "sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf", size = 18459 }, 205 | ] 206 | 207 | [[package]] 208 | name = "packaging" 209 | version = "24.2" 210 | source = { registry = "https://pypi.org/simple" } 211 | sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } 212 | wheels = [ 213 | { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, 214 | ] 215 | 216 | [[package]] 217 | name = "pluggy" 218 | version = "1.5.0" 219 | source = { registry = "https://pypi.org/simple" } 220 | sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } 221 | wheels = [ 222 | { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, 223 | ] 224 | 225 | [[package]] 226 | name = "psycopg" 227 | version = "3.2.3" 228 | source = { registry = "https://pypi.org/simple" } 229 | dependencies = [ 230 | { name = "tzdata", marker = "sys_platform == 'win32'" }, 231 | ] 232 | sdist = { url = "https://files.pythonhosted.org/packages/d1/ad/7ce016ae63e231575df0498d2395d15f005f05e32d3a2d439038e1bd0851/psycopg-3.2.3.tar.gz", hash = "sha256:a5764f67c27bec8bfac85764d23c534af2c27b893550377e37ce59c12aac47a2", size = 155550 } 233 | wheels = [ 234 | { url = "https://files.pythonhosted.org/packages/ce/21/534b8f5bd9734b7a2fcd3a16b1ee82ef6cad81a4796e95ebf4e0c6a24119/psycopg-3.2.3-py3-none-any.whl", hash = "sha256:644d3973fe26908c73d4be746074f6e5224b03c1101d302d9a53bf565ad64907", size = 197934 }, 235 | ] 236 | 237 | [package.optional-dependencies] 238 | binary = [ 239 | { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, 240 | ] 241 | 242 | [[package]] 243 | name = "psycopg-binary" 244 | version = "3.2.3" 245 | source = { registry = "https://pypi.org/simple" } 246 | wheels = [ 247 | { url = "https://files.pythonhosted.org/packages/c6/bf/717c5e51c68e2498b60a6e9f1476cc47953013275a54bf8e23fd5082a72d/psycopg_binary-3.2.3-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:41fdec0182efac66b27478ac15ef54c9ebcecf0e26ed467eb7d6f262a913318b", size = 3360874 }, 248 | { url = "https://files.pythonhosted.org/packages/31/d5/6f9ad6fe5ef80ca9172bc3d028ebae8e9a1ee8aebd917c95c747a5efd85f/psycopg_binary-3.2.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:07d019a786eb020c0f984691aa1b994cb79430061065a694cf6f94056c603d26", size = 3502320 }, 249 | { url = "https://files.pythonhosted.org/packages/fb/7b/c58dd26c27fe7a491141ca765c103e702872ff1c174ebd669d73d7fb0b5d/psycopg_binary-3.2.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c57615791a337378fe5381143259a6c432cdcbb1d3e6428bfb7ce59fff3fb5c", size = 4446950 }, 250 | { url = "https://files.pythonhosted.org/packages/ed/75/acf6a81c788007b7bc0a43b02c22eff7cb19a6ace9e84c32838e86083a3f/psycopg_binary-3.2.3-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8eb9a4e394926b93ad919cad1b0a918e9b4c846609e8c1cfb6b743683f64da0", size = 4252409 }, 251 | { url = "https://files.pythonhosted.org/packages/83/a5/8a01b923fe42acd185d53f24fb98ead717725ede76a4cd183ff293daf1f1/psycopg_binary-3.2.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5905729668ef1418bd36fbe876322dcb0f90b46811bba96d505af89e6fbdce2f", size = 4488121 }, 252 | { url = "https://files.pythonhosted.org/packages/14/8f/b00e65e204340ab1259ecc8d4cc4c1f72c386be5ca7bfb90ae898a058d68/psycopg_binary-3.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd65774ed7d65101b314808b6893e1a75b7664f680c3ef18d2e5c84d570fa393", size = 4190653 }, 253 | { url = "https://files.pythonhosted.org/packages/ce/fc/ba830fc6c9b02b66d1e2fb420736df4d78369760144169a9046f04d72ac6/psycopg_binary-3.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:700679c02f9348a0d0a2adcd33a0275717cd0d0aee9d4482b47d935023629505", size = 3118074 }, 254 | { url = "https://files.pythonhosted.org/packages/b8/75/b62d06930a615435e909e05de126aa3d49f6ec2993d1aa6a99e7faab5570/psycopg_binary-3.2.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:96334bb64d054e36fed346c50c4190bad9d7c586376204f50bede21a913bf942", size = 3100457 }, 255 | { url = "https://files.pythonhosted.org/packages/57/e5/32dc7518325d0010813853a87b19c784d8b11fdb17f5c0e0c148c5ac77af/psycopg_binary-3.2.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9099e443d4cc24ac6872e6a05f93205ba1a231b1a8917317b07c9ef2b955f1f4", size = 3192788 }, 256 | { url = "https://files.pythonhosted.org/packages/23/a3/d1aa04329253c024a2323051774446770d47b43073874a3de8cca797ed8e/psycopg_binary-3.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1985ab05e9abebfbdf3163a16ebb37fbc5d49aff2bf5b3d7375ff0920bbb54cd", size = 3234247 }, 257 | { url = "https://files.pythonhosted.org/packages/03/20/b675af723b9a61d48abd6a3d64cbb9797697d330255d1f8105713d54ed8e/psycopg_binary-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:e90352d7b610b4693fad0feea48549d4315d10f1eba5605421c92bb834e90170", size = 2913413 }, 258 | ] 259 | 260 | [[package]] 261 | name = "pyjwt" 262 | version = "2.10.1" 263 | source = { registry = "https://pypi.org/simple" } 264 | sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 } 265 | wheels = [ 266 | { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 }, 267 | ] 268 | 269 | [[package]] 270 | name = "pytest" 271 | version = "8.3.4" 272 | source = { registry = "https://pypi.org/simple" } 273 | dependencies = [ 274 | { name = "colorama", marker = "sys_platform == 'win32'" }, 275 | { name = "iniconfig" }, 276 | { name = "packaging" }, 277 | { name = "pluggy" }, 278 | ] 279 | sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } 280 | wheels = [ 281 | { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, 282 | ] 283 | 284 | [[package]] 285 | name = "pytest-django" 286 | version = "4.9.0" 287 | source = { registry = "https://pypi.org/simple" } 288 | dependencies = [ 289 | { name = "pytest" }, 290 | ] 291 | sdist = { url = "https://files.pythonhosted.org/packages/02/c0/43c8b2528c24d7f1a48a47e3f7381f5ab2ae8c64634b0c3f4bd843063955/pytest_django-4.9.0.tar.gz", hash = "sha256:8bf7bc358c9ae6f6fc51b6cebb190fe20212196e6807121f11bd6a3b03428314", size = 84067 } 292 | wheels = [ 293 | { url = "https://files.pythonhosted.org/packages/47/fe/54f387ee1b41c9ad59e48fb8368a361fad0600fe404315e31a12bacaea7d/pytest_django-4.9.0-py3-none-any.whl", hash = "sha256:1d83692cb39188682dbb419ff0393867e9904094a549a7d38a3154d5731b2b99", size = 23723 }, 294 | ] 295 | 296 | [[package]] 297 | name = "pytest-factoryboy" 298 | version = "2.7.0" 299 | source = { registry = "https://pypi.org/simple" } 300 | dependencies = [ 301 | { name = "factory-boy" }, 302 | { name = "inflection" }, 303 | { name = "packaging" }, 304 | { name = "pytest" }, 305 | { name = "typing-extensions" }, 306 | ] 307 | sdist = { url = "https://files.pythonhosted.org/packages/a6/bc/179653e8cce651575ac95377e4fdf9afd3c4821ab4bba101aae913ebcc27/pytest_factoryboy-2.7.0.tar.gz", hash = "sha256:67fc54ec8669a3feb8ac60094dd57cd71eb0b20b2c319d2957873674c776a77b", size = 17398 } 308 | wheels = [ 309 | { url = "https://files.pythonhosted.org/packages/c7/56/d3ef25286dc8df9d1da0b325ee4b1b1ffd9736e44f9b30cfbe464e9f4f14/pytest_factoryboy-2.7.0-py3-none-any.whl", hash = "sha256:bf3222db22d954fbf46f4bff902a0a8d82f3fc3594a47c04bbdc0546ff4c59a6", size = 16268 }, 310 | ] 311 | 312 | [[package]] 313 | name = "python-dateutil" 314 | version = "2.9.0.post0" 315 | source = { registry = "https://pypi.org/simple" } 316 | dependencies = [ 317 | { name = "six" }, 318 | ] 319 | sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } 320 | wheels = [ 321 | { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, 322 | ] 323 | 324 | [[package]] 325 | name = "pyyaml" 326 | version = "6.0.2" 327 | source = { registry = "https://pypi.org/simple" } 328 | sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } 329 | wheels = [ 330 | { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, 331 | { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, 332 | { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, 333 | { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, 334 | { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, 335 | { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, 336 | { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, 337 | { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, 338 | { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, 339 | ] 340 | 341 | [[package]] 342 | name = "referencing" 343 | version = "0.35.1" 344 | source = { registry = "https://pypi.org/simple" } 345 | dependencies = [ 346 | { name = "attrs" }, 347 | { name = "rpds-py" }, 348 | ] 349 | sdist = { url = "https://files.pythonhosted.org/packages/99/5b/73ca1f8e72fff6fa52119dbd185f73a907b1989428917b24cff660129b6d/referencing-0.35.1.tar.gz", hash = "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c", size = 62991 } 350 | wheels = [ 351 | { url = "https://files.pythonhosted.org/packages/b7/59/2056f61236782a2c86b33906c025d4f4a0b17be0161b63b70fd9e8775d36/referencing-0.35.1-py3-none-any.whl", hash = "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de", size = 26684 }, 352 | ] 353 | 354 | [[package]] 355 | name = "rpds-py" 356 | version = "0.22.3" 357 | source = { registry = "https://pypi.org/simple" } 358 | sdist = { url = "https://files.pythonhosted.org/packages/01/80/cce854d0921ff2f0a9fa831ba3ad3c65cee3a46711addf39a2af52df2cfd/rpds_py-0.22.3.tar.gz", hash = "sha256:e32fee8ab45d3c2db6da19a5323bc3362237c8b653c70194414b892fd06a080d", size = 26771 } 359 | wheels = [ 360 | { url = "https://files.pythonhosted.org/packages/d0/bf/36d5cc1f2c609ae6e8bf0fc35949355ca9d8790eceb66e6385680c951e60/rpds_py-0.22.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:ea7433ce7e4bfc3a85654aeb6747babe3f66eaf9a1d0c1e7a4435bbdf27fea84", size = 351657 }, 361 | { url = "https://files.pythonhosted.org/packages/24/2a/f1e0fa124e300c26ea9382e59b2d582cba71cedd340f32d1447f4f29fa4e/rpds_py-0.22.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6dd9412824c4ce1aca56c47b0991e65bebb7ac3f4edccfd3f156150c96a7bf25", size = 341829 }, 362 | { url = "https://files.pythonhosted.org/packages/cf/c2/0da1231dd16953845bed60d1a586fcd6b15ceaeb965f4d35cdc71f70f606/rpds_py-0.22.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20070c65396f7373f5df4005862fa162db5d25d56150bddd0b3e8214e8ef45b4", size = 384220 }, 363 | { url = "https://files.pythonhosted.org/packages/c7/73/a4407f4e3a00a9d4b68c532bf2d873d6b562854a8eaff8faa6133b3588ec/rpds_py-0.22.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0b09865a9abc0ddff4e50b5ef65467cd94176bf1e0004184eb915cbc10fc05c5", size = 391009 }, 364 | { url = "https://files.pythonhosted.org/packages/a9/c3/04b7353477ab360fe2563f5f0b176d2105982f97cd9ae80a9c5a18f1ae0f/rpds_py-0.22.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3453e8d41fe5f17d1f8e9c383a7473cd46a63661628ec58e07777c2fff7196dc", size = 426989 }, 365 | { url = "https://files.pythonhosted.org/packages/8d/e6/e4b85b722bcf11398e17d59c0f6049d19cd606d35363221951e6d625fcb0/rpds_py-0.22.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f5d36399a1b96e1a5fdc91e0522544580dbebeb1f77f27b2b0ab25559e103b8b", size = 441544 }, 366 | { url = "https://files.pythonhosted.org/packages/27/fc/403e65e56f65fff25f2973216974976d3f0a5c3f30e53758589b6dc9b79b/rpds_py-0.22.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:009de23c9c9ee54bf11303a966edf4d9087cd43a6003672e6aa7def643d06518", size = 385179 }, 367 | { url = "https://files.pythonhosted.org/packages/57/9b/2be9ff9700d664d51fd96b33d6595791c496d2778cb0b2a634f048437a55/rpds_py-0.22.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1aef18820ef3e4587ebe8b3bc9ba6e55892a6d7b93bac6d29d9f631a3b4befbd", size = 415103 }, 368 | { url = "https://files.pythonhosted.org/packages/bb/a5/03c2ad8ca10994fcf22dd2150dd1d653bc974fa82d9a590494c84c10c641/rpds_py-0.22.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f60bd8423be1d9d833f230fdbccf8f57af322d96bcad6599e5a771b151398eb2", size = 560916 }, 369 | { url = "https://files.pythonhosted.org/packages/ba/2e/be4fdfc8b5b576e588782b56978c5b702c5a2307024120d8aeec1ab818f0/rpds_py-0.22.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:62d9cfcf4948683a18a9aff0ab7e1474d407b7bab2ca03116109f8464698ab16", size = 587062 }, 370 | { url = "https://files.pythonhosted.org/packages/67/e0/2034c221937709bf9c542603d25ad43a68b4b0a9a0c0b06a742f2756eb66/rpds_py-0.22.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9253fc214112405f0afa7db88739294295f0e08466987f1d70e29930262b4c8f", size = 555734 }, 371 | { url = "https://files.pythonhosted.org/packages/ea/ce/240bae07b5401a22482b58e18cfbabaa392409b2797da60223cca10d7367/rpds_py-0.22.3-cp313-cp313-win32.whl", hash = "sha256:fb0ba113b4983beac1a2eb16faffd76cb41e176bf58c4afe3e14b9c681f702de", size = 220663 }, 372 | { url = "https://files.pythonhosted.org/packages/cb/f0/d330d08f51126330467edae2fa4efa5cec8923c87551a79299380fdea30d/rpds_py-0.22.3-cp313-cp313-win_amd64.whl", hash = "sha256:c58e2339def52ef6b71b8f36d13c3688ea23fa093353f3a4fee2556e62086ec9", size = 235503 }, 373 | { url = "https://files.pythonhosted.org/packages/f7/c4/dbe1cc03df013bf2feb5ad00615038050e7859f381e96fb5b7b4572cd814/rpds_py-0.22.3-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f82a116a1d03628a8ace4859556fb39fd1424c933341a08ea3ed6de1edb0283b", size = 347698 }, 374 | { url = "https://files.pythonhosted.org/packages/a4/3a/684f66dd6b0f37499cad24cd1c0e523541fd768576fa5ce2d0a8799c3cba/rpds_py-0.22.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3dfcbc95bd7992b16f3f7ba05af8a64ca694331bd24f9157b49dadeeb287493b", size = 337330 }, 375 | { url = "https://files.pythonhosted.org/packages/82/eb/e022c08c2ce2e8f7683baa313476492c0e2c1ca97227fe8a75d9f0181e95/rpds_py-0.22.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59259dc58e57b10e7e18ce02c311804c10c5a793e6568f8af4dead03264584d1", size = 380022 }, 376 | { url = "https://files.pythonhosted.org/packages/e4/21/5a80e653e4c86aeb28eb4fea4add1f72e1787a3299687a9187105c3ee966/rpds_py-0.22.3-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5725dd9cc02068996d4438d397e255dcb1df776b7ceea3b9cb972bdb11260a83", size = 390754 }, 377 | { url = "https://files.pythonhosted.org/packages/37/a4/d320a04ae90f72d080b3d74597074e62be0a8ecad7d7321312dfe2dc5a6a/rpds_py-0.22.3-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99b37292234e61325e7a5bb9689e55e48c3f5f603af88b1642666277a81f1fbd", size = 423840 }, 378 | { url = "https://files.pythonhosted.org/packages/87/70/674dc47d93db30a6624279284e5631be4c3a12a0340e8e4f349153546728/rpds_py-0.22.3-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27b1d3b3915a99208fee9ab092b8184c420f2905b7d7feb4aeb5e4a9c509b8a1", size = 438970 }, 379 | { url = "https://files.pythonhosted.org/packages/3f/64/9500f4d66601d55cadd21e90784cfd5d5f4560e129d72e4339823129171c/rpds_py-0.22.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f612463ac081803f243ff13cccc648578e2279295048f2a8d5eb430af2bae6e3", size = 383146 }, 380 | { url = "https://files.pythonhosted.org/packages/4d/45/630327addb1d17173adcf4af01336fd0ee030c04798027dfcb50106001e0/rpds_py-0.22.3-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f73d3fef726b3243a811121de45193c0ca75f6407fe66f3f4e183c983573e130", size = 408294 }, 381 | { url = "https://files.pythonhosted.org/packages/5f/ef/8efb3373cee54ea9d9980b772e5690a0c9e9214045a4e7fa35046e399fee/rpds_py-0.22.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3f21f0495edea7fdbaaa87e633a8689cd285f8f4af5c869f27bc8074638ad69c", size = 556345 }, 382 | { url = "https://files.pythonhosted.org/packages/54/01/151d3b9ef4925fc8f15bfb131086c12ec3c3d6dd4a4f7589c335bf8e85ba/rpds_py-0.22.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:1e9663daaf7a63ceccbbb8e3808fe90415b0757e2abddbfc2e06c857bf8c5e2b", size = 582292 }, 383 | { url = "https://files.pythonhosted.org/packages/30/89/35fc7a6cdf3477d441c7aca5e9bbf5a14e0f25152aed7f63f4e0b141045d/rpds_py-0.22.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a76e42402542b1fae59798fab64432b2d015ab9d0c8c47ba7addddbaf7952333", size = 553855 }, 384 | { url = "https://files.pythonhosted.org/packages/8f/e0/830c02b2457c4bd20a8c5bb394d31d81f57fbefce2dbdd2e31feff4f7003/rpds_py-0.22.3-cp313-cp313t-win32.whl", hash = "sha256:69803198097467ee7282750acb507fba35ca22cc3b85f16cf45fb01cb9097730", size = 219100 }, 385 | { url = "https://files.pythonhosted.org/packages/f8/30/7ac943f69855c2db77407ae363484b915d861702dbba1aa82d68d57f42be/rpds_py-0.22.3-cp313-cp313t-win_amd64.whl", hash = "sha256:f5cf2a0c2bdadf3791b5c205d55a37a54025c6e18a71c71f82bb536cf9a454bf", size = 233794 }, 386 | ] 387 | 388 | [[package]] 389 | name = "six" 390 | version = "1.17.0" 391 | source = { registry = "https://pypi.org/simple" } 392 | sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } 393 | wheels = [ 394 | { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, 395 | ] 396 | 397 | [[package]] 398 | name = "sqlparse" 399 | version = "0.5.3" 400 | source = { registry = "https://pypi.org/simple" } 401 | sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999 } 402 | wheels = [ 403 | { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415 }, 404 | ] 405 | 406 | [[package]] 407 | name = "typing-extensions" 408 | version = "4.12.2" 409 | source = { registry = "https://pypi.org/simple" } 410 | sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } 411 | wheels = [ 412 | { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, 413 | ] 414 | 415 | [[package]] 416 | name = "tzdata" 417 | version = "2024.2" 418 | source = { registry = "https://pypi.org/simple" } 419 | sdist = { url = "https://files.pythonhosted.org/packages/e1/34/943888654477a574a86a98e9896bae89c7aa15078ec29f490fef2f1e5384/tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc", size = 193282 } 420 | wheels = [ 421 | { url = "https://files.pythonhosted.org/packages/a6/ab/7e5f53c3b9d14972843a647d8d7a853969a58aecc7559cb3267302c94774/tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd", size = 346586 }, 422 | ] 423 | 424 | [[package]] 425 | name = "uritemplate" 426 | version = "4.1.1" 427 | source = { registry = "https://pypi.org/simple" } 428 | sdist = { url = "https://files.pythonhosted.org/packages/d2/5a/4742fdba39cd02a56226815abfa72fe0aa81c33bed16ed045647d6000eba/uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0", size = 273898 } 429 | wheels = [ 430 | { url = "https://files.pythonhosted.org/packages/81/c0/7461b49cd25aeece13766f02ee576d1db528f1c37ce69aee300e075b485b/uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e", size = 10356 }, 431 | ] 432 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["frontend/biome.json"] 3 | } 4 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres 4 | expose: 5 | - "5432" 6 | ports: 7 | - "5432:5432" 8 | volumes: 9 | - ./data:/var/lib/postgresql/data 10 | environment: 11 | POSTGRES_PASSWORD: "change-password" 12 | POSTGRES_DB: "db" 13 | healthcheck: 14 | test: ["CMD-SHELL", "pg_isready -U postgres"] 15 | interval: 2s 16 | timeout: 2s 17 | retries: 10 18 | api: 19 | command: bash -c "uv sync && uv run -- python manage.py migrate && uv run -- python manage.py runserver 0.0.0.0:8000" 20 | build: 21 | context: backend 22 | expose: 23 | - "8000" 24 | ports: 25 | - "8000:8000" 26 | volumes: 27 | - ./backend:/app 28 | env_file: 29 | - .env.backend 30 | depends_on: 31 | db: 32 | condition: service_healthy 33 | web: 34 | command: bash -c "pnpm install -r && pnpm --filter web dev" 35 | build: 36 | context: frontend 37 | volumes: 38 | - ./frontend:/app 39 | expose: 40 | - "3000" 41 | ports: 42 | - "3000:3000" 43 | env_file: 44 | - .env.frontend 45 | depends_on: 46 | - api 47 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:21 2 | 3 | ENV NEXT_TELEMETRY_DISABLED=1 4 | 5 | RUN npm install -g pnpm 6 | 7 | WORKDIR /app 8 | 9 | EXPOSE 3000 10 | -------------------------------------------------------------------------------- /frontend/apps/web/actions/change-password-action.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { getApiClient } from '@/lib/api' 4 | import { authOptions } from '@/lib/auth' 5 | import type { changePasswordFormSchema } from '@/lib/validation' 6 | import { ApiError, type UserChangePasswordError } from '@frontend/types/api' 7 | import { getServerSession } from 'next-auth' 8 | import type { z } from 'zod' 9 | 10 | export type ChangePasswordFormSchema = z.infer 11 | 12 | export async function changePasswordAction( 13 | data: ChangePasswordFormSchema 14 | ): Promise { 15 | const session = await getServerSession(authOptions) 16 | 17 | try { 18 | const apiClient = await getApiClient(session) 19 | 20 | await apiClient.users.usersChangePasswordCreate({ 21 | password: data.password, 22 | password_new: data.passwordNew, 23 | password_retype: data.passwordRetype 24 | }) 25 | 26 | return true 27 | } catch (error) { 28 | if (error instanceof ApiError) { 29 | return error.body as UserChangePasswordError 30 | } 31 | } 32 | 33 | return false 34 | } 35 | -------------------------------------------------------------------------------- /frontend/apps/web/actions/delete-account-action.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { getApiClient } from '@/lib/api' 4 | import { authOptions } from '@/lib/auth' 5 | import type { deleteAccountFormSchema } from '@/lib/validation' 6 | import { ApiError } from '@frontend/types/api' 7 | import { getServerSession } from 'next-auth' 8 | import type { z } from 'zod' 9 | 10 | export type DeleteAccountFormSchema = z.infer 11 | 12 | export async function deleteAccountAction( 13 | data: DeleteAccountFormSchema 14 | ): Promise { 15 | const session = await getServerSession(authOptions) 16 | 17 | try { 18 | const apiClient = await getApiClient(session) 19 | 20 | if (session !== null) { 21 | await apiClient.users.usersDeleteAccountDestroy() 22 | 23 | return true 24 | } 25 | } catch (error) { 26 | if (error instanceof ApiError) { 27 | return false 28 | } 29 | } 30 | 31 | return false 32 | } 33 | -------------------------------------------------------------------------------- /frontend/apps/web/actions/profile-action.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { getApiClient } from '@/lib/api' 4 | import { authOptions } from '@/lib/auth' 5 | import type { profileFormSchema } from '@/lib/validation' 6 | import { ApiError, type UserCurrentError } from '@frontend/types/api' 7 | import { getServerSession } from 'next-auth' 8 | import type { z } from 'zod' 9 | 10 | export type ProfileFormSchema = z.infer 11 | 12 | export async function profileAction( 13 | data: ProfileFormSchema 14 | ): Promise { 15 | const session = await getServerSession(authOptions) 16 | 17 | try { 18 | const apiClient = await getApiClient(session) 19 | 20 | await apiClient.users.usersMePartialUpdate({ 21 | first_name: data.firstName, 22 | last_name: data.lastName 23 | }) 24 | 25 | return true 26 | } catch (error) { 27 | if (error instanceof ApiError) { 28 | return error.body as UserCurrentError 29 | } 30 | } 31 | 32 | return false 33 | } 34 | -------------------------------------------------------------------------------- /frontend/apps/web/actions/register-action.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { getApiClient } from '@/lib/api' 4 | import type { registerFormSchema } from '@/lib/validation' 5 | import { ApiError, type UserCreateError } from '@frontend/types/api' 6 | import type { z } from 'zod' 7 | 8 | export type RegisterFormSchema = z.infer 9 | 10 | export async function registerAction( 11 | data: RegisterFormSchema 12 | ): Promise { 13 | try { 14 | const apiClient = await getApiClient() 15 | 16 | await apiClient.users.usersCreate({ 17 | username: data.username, 18 | password: data.password, 19 | password_retype: data.passwordRetype 20 | }) 21 | 22 | return true 23 | } catch (error) { 24 | if (error instanceof ApiError) { 25 | return error.body as UserCreateError 26 | } 27 | } 28 | 29 | return false 30 | } 31 | -------------------------------------------------------------------------------- /frontend/apps/web/app/(account)/change-password/page.tsx: -------------------------------------------------------------------------------- 1 | import { changePasswordAction } from '@/actions/change-password-action' 2 | import { ChangePaswordForm } from '@/components/forms/change-password-form' 3 | import type { Metadata } from 'next' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Change password - Turbo' 7 | } 8 | 9 | export default function ChangePassword() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /frontend/apps/web/app/(account)/delete-account/page.tsx: -------------------------------------------------------------------------------- 1 | import { deleteAccountAction } from '@/actions/delete-account-action' 2 | import { DeleteAccountForm } from '@/components/forms/delete-account-form' 3 | import type { Metadata } from 'next' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Delete account - Turbo' 7 | } 8 | 9 | export default function DeleteAccount() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /frontend/apps/web/app/(account)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { authOptions } from '@/lib/auth' 2 | import { getServerSession } from 'next-auth' 3 | import { redirect } from 'next/navigation' 4 | 5 | export default async function AccountLayout({ 6 | children 7 | }: { 8 | children: React.ReactNode 9 | }) { 10 | const session = await getServerSession(authOptions) 11 | 12 | if (session === null) { 13 | return redirect('/') 14 | } 15 | 16 | return ( 17 |
18 |
{children}
19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /frontend/apps/web/app/(account)/profile/page.tsx: -------------------------------------------------------------------------------- 1 | import { profileAction } from '@/actions/profile-action' 2 | import { ProfileForm } from '@/components/forms/profile-form' 3 | import { getApiClient } from '@/lib/api' 4 | import { authOptions } from '@/lib/auth' 5 | import type { Metadata } from 'next' 6 | import { getServerSession } from 'next-auth' 7 | 8 | export const metadata: Metadata = { 9 | title: 'Profile - Turbo' 10 | } 11 | 12 | export default async function Profile() { 13 | const session = await getServerSession(authOptions) 14 | const apiClient = await getApiClient(session) 15 | 16 | return ( 17 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /frontend/apps/web/app/(auth)/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from '@/lib/auth' 2 | import NextAuth from 'next-auth' 3 | 4 | const handler = NextAuth(authOptions) 5 | 6 | export { handler as GET, handler as POST } 7 | -------------------------------------------------------------------------------- /frontend/apps/web/app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | export default function AuthLayout({ 2 | children 3 | }: { children: React.ReactNode }) { 4 | return ( 5 |
6 |
{children}
7 |
8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /frontend/apps/web/app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { LoginForm } from '@/components/forms/login-form' 2 | import type { Metadata } from 'next' 3 | 4 | export const metadata: Metadata = { 5 | title: 'Login - Turbo' 6 | } 7 | 8 | export default function Login() { 9 | return 10 | } 11 | -------------------------------------------------------------------------------- /frontend/apps/web/app/(auth)/register/page.tsx: -------------------------------------------------------------------------------- 1 | import { registerAction } from '@/actions/register-action' 2 | import { RegisterForm } from '@/components/forms/register-form' 3 | import type { Metadata } from 'next' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Register - Turbo' 7 | } 8 | 9 | export default function Register() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /frontend/apps/web/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { AuthProvider } from '@/providers/auth-provider' 2 | import type { Metadata } from 'next' 3 | import { Inter } from 'next/font/google' 4 | import { twMerge } from 'tailwind-merge' 5 | 6 | import '@frontend/ui/styles/globals.css' 7 | 8 | const inter = Inter({ subsets: ['latin'] }) 9 | 10 | export const metadata: Metadata = { 11 | title: 'Turbo - Django & Next.js Bootstrap Template' 12 | } 13 | 14 | export default function RootLayout({ 15 | children 16 | }: { children: React.ReactNode }) { 17 | return ( 18 | 19 | 25 | 26 |
27 |
{children}
28 |
29 |
30 | 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /frontend/apps/web/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { PagesOverview } from '@/components/pages-overview' 2 | import { UserSession } from '@/components/user-session' 3 | 4 | export default function Home() { 5 | return ( 6 | <> 7 |

8 | Turbo - Django & Next.js starter kit 9 |

10 | 11 |

12 | Turbo is minimal and opiniated starter kit for Django & Next.js projects 13 | connected via REST API. For the documentation please visit GitHub 14 | repository and in case of some feedback, dont hesitate to raise a ticket 15 | in{' '} 16 | 20 | Issues section 21 | 22 | . 23 |

24 | 25 | 26 | 27 |
28 | 29 | 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /frontend/apps/web/components/forms/change-password-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { changePasswordAction } from '@/actions/change-password-action' 4 | import { fieldApiError } from '@/lib/forms' 5 | import { changePasswordFormSchema } from '@/lib/validation' 6 | import { FormHeader } from '@frontend/ui/forms/form-header' 7 | import { SubmitField } from '@frontend/ui/forms/submit-field' 8 | import { TextField } from '@frontend/ui/forms/text-field' 9 | import { SuccessMessage } from '@frontend/ui/messages/success-message' 10 | import { zodResolver } from '@hookform/resolvers/zod' 11 | import { useState } from 'react' 12 | import { useForm } from 'react-hook-form' 13 | import type { z } from 'zod' 14 | 15 | export type ChangePasswordFormSchema = z.infer 16 | 17 | export function ChangePaswordForm({ 18 | onSubmitHandler 19 | }: { 20 | onSubmitHandler: typeof changePasswordAction 21 | }) { 22 | const [success, setSuccess] = useState(false) 23 | 24 | const { formState, handleSubmit, register, reset, setError } = 25 | useForm({ 26 | resolver: zodResolver(changePasswordFormSchema) 27 | }) 28 | 29 | return ( 30 | <> 31 | 35 | 36 | {success && ( 37 | Password has been successfully changed 38 | )} 39 | 40 |
{ 43 | const res = await onSubmitHandler(data) 44 | 45 | if (res !== true && typeof res !== 'boolean') { 46 | setSuccess(false) 47 | fieldApiError('password', 'password', res, setError) 48 | fieldApiError('password_new', 'passwordNew', res, setError) 49 | fieldApiError('password_retype', 'passwordRetype', res, setError) 50 | } else { 51 | reset() 52 | setSuccess(true) 53 | } 54 | })} 55 | > 56 | 62 | 63 | 69 | 70 | 76 | 77 | 78 | Change password 79 | 80 | 81 | 82 | ) 83 | } 84 | -------------------------------------------------------------------------------- /frontend/apps/web/components/forms/delete-account-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { 4 | DeleteAccountFormSchema, 5 | deleteAccountAction 6 | } from '@/actions/delete-account-action' 7 | import { deleteAccountFormSchema } from '@/lib/validation' 8 | import { FormHeader } from '@frontend/ui/forms/form-header' 9 | import { SubmitField } from '@frontend/ui/forms/submit-field' 10 | import { TextField } from '@frontend/ui/forms/text-field' 11 | import { zodResolver } from '@hookform/resolvers/zod' 12 | import { signOut, useSession } from 'next-auth/react' 13 | import { useEffect } from 'react' 14 | import { useForm } from 'react-hook-form' 15 | 16 | export function DeleteAccountForm({ 17 | onSubmitHandler 18 | }: { onSubmitHandler: typeof deleteAccountAction }) { 19 | const session = useSession() 20 | 21 | const { formState, handleSubmit, register, reset, setValue } = 22 | useForm({ 23 | resolver: zodResolver(deleteAccountFormSchema) 24 | }) 25 | 26 | useEffect(() => { 27 | if (session.data?.user.username) { 28 | setValue('usernameCurrent', session.data?.user.username) 29 | } 30 | }, [setValue, session.data?.user.username]) 31 | 32 | return ( 33 | <> 34 | 38 | 39 |
{ 42 | const res = await onSubmitHandler(data) 43 | 44 | if (res) { 45 | reset() 46 | signOut() 47 | } 48 | })} 49 | > 50 | 56 | 57 | Delete account 58 | 59 | 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /frontend/apps/web/components/forms/login-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { loginFormSchema } from '@/lib/validation' 4 | import { FormFooter } from '@frontend/ui/forms/form-footer' 5 | import { FormHeader } from '@frontend/ui/forms/form-header' 6 | import { SubmitField } from '@frontend/ui/forms/submit-field' 7 | import { TextField } from '@frontend/ui/forms/text-field' 8 | import { ErrorMessage } from '@frontend/ui/messages/error-message' 9 | import { zodResolver } from '@hookform/resolvers/zod' 10 | import { signIn } from 'next-auth/react' 11 | import { useSearchParams } from 'next/navigation' 12 | import { useForm } from 'react-hook-form' 13 | import type { z } from 'zod' 14 | 15 | type LoginFormSchema = z.infer 16 | 17 | export function LoginForm() { 18 | const search = useSearchParams() 19 | 20 | const { register, handleSubmit, formState } = useForm({ 21 | resolver: zodResolver(loginFormSchema) 22 | }) 23 | 24 | const onSubmitHandler = handleSubmit((data) => { 25 | signIn('credentials', { 26 | username: data.username, 27 | password: data.password, 28 | callbackUrl: '/' 29 | }) 30 | }) 31 | 32 | return ( 33 | <> 34 | 38 | 39 | {search.has('error') && search.get('error') === 'CredentialsSignin' && ( 40 | Provided account does not exists. 41 | )} 42 | 43 |
48 | 55 | 56 | 63 | 64 | Sign in 65 | 66 | 67 | 72 | 73 | ) 74 | } 75 | -------------------------------------------------------------------------------- /frontend/apps/web/components/forms/profile-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { profileAction } from '@/actions/profile-action' 4 | import { fieldApiError } from '@/lib/forms' 5 | import { profileFormSchema } from '@/lib/validation' 6 | import type { UserCurrent } from '@frontend/types/api' 7 | import { FormHeader } from '@frontend/ui/forms/form-header' 8 | import { SubmitField } from '@frontend/ui/forms/submit-field' 9 | import { TextField } from '@frontend/ui/forms/text-field' 10 | import { SuccessMessage } from '@frontend/ui/messages/success-message' 11 | import { zodResolver } from '@hookform/resolvers/zod' 12 | import { useState } from 'react' 13 | import { useForm } from 'react-hook-form' 14 | import type { z } from 'zod' 15 | 16 | export type ProfileFormSchema = z.infer 17 | 18 | export function ProfileForm({ 19 | currentUser, 20 | onSubmitHandler 21 | }: { 22 | currentUser: Promise 23 | onSubmitHandler: typeof profileAction 24 | }) { 25 | const [success, setSuccess] = useState(false) 26 | 27 | const { formState, handleSubmit, register, setError } = 28 | useForm({ 29 | resolver: zodResolver(profileFormSchema), 30 | defaultValues: async () => { 31 | const user = await currentUser 32 | 33 | return { 34 | firstName: user.first_name || '', 35 | lastName: user.last_name || '' 36 | } 37 | } 38 | }) 39 | 40 | return ( 41 | <> 42 | 46 | 47 |
{ 50 | const res = await onSubmitHandler(data) 51 | 52 | if (res !== true && typeof res !== 'boolean') { 53 | setSuccess(false) 54 | 55 | fieldApiError('first_name', 'firstName', res, setError) 56 | fieldApiError('last_name', 'lastName', res, setError) 57 | } else { 58 | setSuccess(true) 59 | } 60 | })} 61 | > 62 | {success && ( 63 | Profile has been succesfully updated 64 | )} 65 | 66 | 72 | 73 | 79 | 80 | 81 | Update profile 82 | 83 | 84 | 85 | ) 86 | } 87 | -------------------------------------------------------------------------------- /frontend/apps/web/components/forms/register-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { 4 | RegisterFormSchema, 5 | registerAction 6 | } from '@/actions/register-action' 7 | import { fieldApiError } from '@/lib/forms' 8 | import { registerFormSchema } from '@/lib/validation' 9 | import { FormFooter } from '@frontend/ui/forms/form-footer' 10 | import { FormHeader } from '@frontend/ui/forms/form-header' 11 | import { SubmitField } from '@frontend/ui/forms/submit-field' 12 | import { TextField } from '@frontend/ui/forms/text-field' 13 | import { zodResolver } from '@hookform/resolvers/zod' 14 | import { signIn } from 'next-auth/react' 15 | import { useForm } from 'react-hook-form' 16 | 17 | export function RegisterForm({ 18 | onSubmitHandler 19 | }: { onSubmitHandler: typeof registerAction }) { 20 | const { formState, handleSubmit, register, setError } = 21 | useForm({ 22 | resolver: zodResolver(registerFormSchema) 23 | }) 24 | 25 | return ( 26 | <> 27 | 31 | 32 |
{ 35 | const res = await onSubmitHandler(data) 36 | 37 | if (res === true) { 38 | signIn() 39 | } else if (typeof res !== 'boolean') { 40 | fieldApiError('username', 'username', res, setError) 41 | fieldApiError('password', 'password', res, setError) 42 | fieldApiError('password_retype', 'passwordRetype', res, setError) 43 | } 44 | })} 45 | > 46 | 53 | 54 | 61 | 62 | 69 | 70 | Sign up 71 | 72 | 73 | 78 | 79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /frontend/apps/web/components/pages-overview.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { signIn, signOut } from 'next-auth/react' 4 | import Link from 'next/link' 5 | 6 | export function SignInLink() { 7 | return ( 8 | 15 | ) 16 | } 17 | 18 | export function SignOutLink() { 19 | return ( 20 | 27 | ) 28 | } 29 | 30 | export function PagesOverview() { 31 | return ( 32 |
    33 |
  • 34 | Authenticated pages 35 | 36 |
      37 |
    • 38 | 39 | Profile 40 | 41 |
    • 42 | 43 |
    • 44 | 45 | Change password 46 | 47 |
    • 48 | 49 |
    • 50 | 51 | Delete account 52 | 53 |
    • 54 |
    55 |
  • 56 | 57 |
  • 58 | Anonymous pages 59 | 60 | 75 |
  • 76 |
77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /frontend/apps/web/components/user-session.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useSession } from 'next-auth/react' 4 | import { twMerge } from 'tailwind-merge' 5 | 6 | export function UserSession() { 7 | const session = useSession() 8 | 9 | return ( 10 |
    11 |
  • 12 | Session 13 | 20 | {session.status} 21 | 22 |
  • 23 | 24 |
  • 25 | Username 26 | 27 | {session.data?.user?.username || 'undefined'} 28 | 29 |
  • 30 |
31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /frontend/apps/web/lib/api.ts: -------------------------------------------------------------------------------- 1 | import { ApiClient } from '@frontend/types/api' 2 | import type { Session } from 'next-auth' 3 | 4 | export async function getApiClient(session?: Session | null) { 5 | return new ApiClient({ 6 | BASE: process.env.API_URL, 7 | HEADERS: { 8 | ...(session && { 9 | Authorization: `Bearer ${session.accessToken}` 10 | }) 11 | } 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /frontend/apps/web/lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { ApiError } from '@frontend/types/api' 2 | import type { AuthOptions } from 'next-auth' 3 | import CredentialsProvider from 'next-auth/providers/credentials' 4 | import { getApiClient } from './api' 5 | 6 | function decodeToken(token: string): { 7 | token_type: string 8 | exp: number 9 | iat: number 10 | jti: string 11 | user_id: number 12 | } { 13 | return JSON.parse(atob(token.split('.')[1])) 14 | } 15 | 16 | const authOptions: AuthOptions = { 17 | session: { 18 | strategy: 'jwt' 19 | }, 20 | pages: { 21 | signIn: '/login' 22 | }, 23 | callbacks: { 24 | session: async ({ session, token }) => { 25 | const access = decodeToken(token.access) 26 | const refresh = decodeToken(token.refresh) 27 | 28 | if (Date.now() / 1000 > access.exp && Date.now() / 1000 > refresh.exp) { 29 | return Promise.reject({ 30 | error: new Error('Refresh token expired') 31 | }) 32 | } 33 | 34 | session.user = { 35 | id: access.user_id, 36 | username: token.username 37 | } 38 | 39 | session.refreshToken = token.refresh 40 | session.accessToken = token.access 41 | 42 | return session 43 | }, 44 | jwt: async ({ token, user }) => { 45 | if (user?.username) { 46 | return { ...token, ...user } 47 | } 48 | 49 | // Refresh token 50 | if (Date.now() / 1000 > decodeToken(token.access).exp) { 51 | const apiClient = await getApiClient() 52 | const res = await apiClient.token.tokenRefreshCreate({ 53 | access: token.access, 54 | refresh: token.refresh 55 | }) 56 | 57 | token.access = res.access 58 | } 59 | 60 | return { ...token, ...user } 61 | } 62 | }, 63 | providers: [ 64 | CredentialsProvider({ 65 | name: 'credentials', 66 | credentials: { 67 | username: { 68 | label: 'Email', 69 | type: 'text' 70 | }, 71 | password: { label: 'Password', type: 'password' } 72 | }, 73 | async authorize(credentials) { 74 | if (credentials === undefined) { 75 | return null 76 | } 77 | 78 | try { 79 | const apiClient = await getApiClient() 80 | const res = await apiClient.token.tokenCreate({ 81 | username: credentials.username, 82 | password: credentials.password, 83 | access: '', 84 | refresh: '' 85 | }) 86 | 87 | return { 88 | id: decodeToken(res.access).user_id, 89 | username: credentials.username, 90 | access: res.access, 91 | refresh: res.refresh 92 | } 93 | } catch (error) { 94 | if (error instanceof ApiError) { 95 | return null 96 | } 97 | } 98 | 99 | return null 100 | } 101 | }) 102 | ] 103 | } 104 | 105 | export { authOptions } 106 | -------------------------------------------------------------------------------- /frontend/apps/web/lib/forms.ts: -------------------------------------------------------------------------------- 1 | import type { FieldValues, Path, UseFormSetError } from 'react-hook-form' 2 | 3 | /** 4 | * Helper function processing error messages from API server 5 | * 6 | * Example API response (4xx status code): 7 | * 8 | * { 9 | * "first_name": [ 10 | * "first validation message", 11 | * "second validation message" 12 | * ] 13 | * } 14 | */ 15 | export function fieldApiError( 16 | fieldName: string, 17 | fieldPath: Path, 18 | response: { [key: string]: string[] }, 19 | setError: UseFormSetError 20 | ) { 21 | if (typeof response !== 'boolean' && fieldName in response) { 22 | for (const error of response[fieldName]) { 23 | setError(fieldPath, { 24 | message: error 25 | }) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /frontend/apps/web/lib/validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | const loginFormSchema = z.object({ 4 | username: z.string().min(6), 5 | password: z.string().min(8) 6 | }) 7 | 8 | const registerFormSchema = z 9 | .object({ 10 | username: z.string().min(6), 11 | password: z.string().min(6), 12 | passwordRetype: z.string().min(6) 13 | }) 14 | .refine((data) => data.password === data.passwordRetype, { 15 | message: 'Passwords are not matching', 16 | path: ['passwordRetype'] 17 | }) 18 | 19 | const profileFormSchema = z.object({ 20 | firstName: z.string().optional(), 21 | lastName: z.string().optional() 22 | }) 23 | 24 | const deleteAccountFormSchema = z 25 | .object({ 26 | username: z.string().min(6), 27 | usernameCurrent: z.string().min(6).optional() 28 | }) 29 | .passthrough() 30 | .refine((data) => data.username === data.usernameCurrent, { 31 | message: 'Username is not matching', 32 | path: ['username'] 33 | }) 34 | 35 | const changePasswordFormSchema = z 36 | .object({ 37 | password: z.string().min(8), 38 | passwordNew: z.string().min(8), 39 | passwordRetype: z.string().min(8) 40 | }) 41 | .refine((data) => data.passwordNew !== data.password, { 42 | message: 'Both new and current passwords are same', 43 | path: ['passwordNew'] 44 | }) 45 | .refine((data) => data.passwordNew === data.passwordRetype, { 46 | message: 'Passwords are not matching', 47 | path: ['passwordRetype'] 48 | }) 49 | 50 | export { 51 | changePasswordFormSchema, 52 | deleteAccountFormSchema, 53 | loginFormSchema, 54 | profileFormSchema, 55 | registerFormSchema 56 | } 57 | -------------------------------------------------------------------------------- /frontend/apps/web/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import 'next-auth' 2 | 3 | declare module 'next-auth' { 4 | interface User { 5 | id: number 6 | username: string 7 | } 8 | 9 | interface Session { 10 | refreshToken: string 11 | accessToken: string 12 | user: User 13 | } 14 | } 15 | 16 | declare module 'next-auth/jwt' { 17 | interface JWT { 18 | username: string 19 | access: string 20 | refresh: string 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/apps/web/next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from 'next' 2 | 3 | const nextConfig: NextConfig = { 4 | output: 'standalone', 5 | transpilePackages: ['@frontend/types', '@frontend/ui'] 6 | } 7 | 8 | export default nextConfig 9 | -------------------------------------------------------------------------------- /frontend/apps/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@frontend/types": "workspace:^", 13 | "@frontend/ui": "workspace:^", 14 | "@hookform/resolvers": "^3.9.1", 15 | "next": "^15.2.4", 16 | "next-auth": "^4.24.11", 17 | "react-hook-form": "^7.54.2", 18 | "zod": "^3.24.1" 19 | }, 20 | "devDependencies": { 21 | "typescript": "5.7.2" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /frontend/apps/web/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@frontend/ui/postcss.config') 2 | -------------------------------------------------------------------------------- /frontend/apps/web/providers/auth-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { SessionProvider } from 'next-auth/react' 4 | import type { PropsWithChildren } from 'react' 5 | 6 | export const AuthProvider: React.FC = ({ children }) => { 7 | return {children} 8 | } 9 | -------------------------------------------------------------------------------- /frontend/apps/web/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | export * from '@frontend/ui/tailwind.config' 2 | -------------------------------------------------------------------------------- /frontend/apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./*"] 24 | } 25 | }, 26 | "include": [ 27 | "next-env.d.ts", 28 | "**/*.ts", 29 | "**/*.tsx", 30 | ".next/types/**/*.ts", 31 | "next-auth.d.ts" 32 | ], 33 | "exclude": ["node_modules"] 34 | } 35 | -------------------------------------------------------------------------------- /frontend/biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "files": { 4 | "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"], 5 | "ignore": [".next", "packages/types/api"] 6 | }, 7 | "formatter": { 8 | "indentStyle": "space", 9 | "indentWidth": 2, 10 | "lineWidth": 80, 11 | "lineEnding": "lf" 12 | }, 13 | "javascript": { 14 | "formatter": { 15 | "semicolons": "asNeeded", 16 | "quoteStyle": "single", 17 | "jsxQuoteStyle": "double", 18 | "trailingCommas": "none" 19 | } 20 | }, 21 | "linter": { 22 | "enabled": true 23 | }, 24 | "organizeImports": { 25 | "enabled": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "sideEffects": false, 6 | "dependencies": { 7 | "react": "^19.0.0", 8 | "react-dom": "^19.0.0", 9 | "tailwind-merge": "^2.6.0" 10 | }, 11 | "devDependencies": { 12 | "@biomejs/biome": "1.9.4", 13 | "@types/node": "^20.17.10", 14 | "@types/react": "^19.0.2", 15 | "@types/react-dom": "^19.0.2", 16 | "autoprefixer": "^10.4.20", 17 | "openapi-typescript-codegen": "^0.29.0", 18 | "postcss": "^8.4.49", 19 | "tailwindcss": "^3.4.17" 20 | }, 21 | "scripts": { 22 | "openapi:generate": "openapi -i http://api:8000/api/schema -o packages/types/api --name ApiClient" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /frontend/packages/types/api/Api.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do no edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | import type { BaseHttpRequest } from './core/BaseHttpRequest' 6 | import type { OpenAPIConfig } from './core/OpenAPI' 7 | import { FetchHttpRequest } from './core/FetchHttpRequest' 8 | 9 | import { SchemaService } from './services/SchemaService' 10 | import { TokenService } from './services/TokenService' 11 | 12 | type HttpRequestConstructor = new (config: OpenAPIConfig) => BaseHttpRequest 13 | 14 | export class Api { 15 | public readonly schema: SchemaService 16 | public readonly token: TokenService 17 | 18 | public readonly request: BaseHttpRequest 19 | 20 | constructor( 21 | config?: Partial, 22 | HttpRequest: HttpRequestConstructor = FetchHttpRequest 23 | ) { 24 | this.request = new HttpRequest({ 25 | BASE: config?.BASE ?? '', 26 | VERSION: config?.VERSION ?? '0.0.0', 27 | WITH_CREDENTIALS: config?.WITH_CREDENTIALS ?? false, 28 | CREDENTIALS: config?.CREDENTIALS ?? 'include', 29 | TOKEN: config?.TOKEN, 30 | USERNAME: config?.USERNAME, 31 | PASSWORD: config?.PASSWORD, 32 | HEADERS: config?.HEADERS, 33 | ENCODE_PATH: config?.ENCODE_PATH 34 | }) 35 | 36 | this.schema = new SchemaService(this.request) 37 | this.token = new TokenService(this.request) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /frontend/packages/types/api/ApiClient.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do no edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | import type { BaseHttpRequest } from './core/BaseHttpRequest' 6 | import type { OpenAPIConfig } from './core/OpenAPI' 7 | import { FetchHttpRequest } from './core/FetchHttpRequest' 8 | 9 | import { SchemaService } from './services/SchemaService' 10 | import { TokenService } from './services/TokenService' 11 | import { UsersService } from './services/UsersService' 12 | 13 | type HttpRequestConstructor = new (config: OpenAPIConfig) => BaseHttpRequest 14 | 15 | export class ApiClient { 16 | public readonly schema: SchemaService 17 | public readonly token: TokenService 18 | public readonly users: UsersService 19 | 20 | public readonly request: BaseHttpRequest 21 | 22 | constructor( 23 | config?: Partial, 24 | HttpRequest: HttpRequestConstructor = FetchHttpRequest 25 | ) { 26 | this.request = new HttpRequest({ 27 | BASE: config?.BASE ?? '', 28 | VERSION: config?.VERSION ?? '0.0.0', 29 | WITH_CREDENTIALS: config?.WITH_CREDENTIALS ?? false, 30 | CREDENTIALS: config?.CREDENTIALS ?? 'include', 31 | TOKEN: config?.TOKEN, 32 | USERNAME: config?.USERNAME, 33 | PASSWORD: config?.PASSWORD, 34 | HEADERS: config?.HEADERS, 35 | ENCODE_PATH: config?.ENCODE_PATH 36 | }) 37 | 38 | this.schema = new SchemaService(this.request) 39 | this.token = new TokenService(this.request) 40 | this.users = new UsersService(this.request) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /frontend/packages/types/api/core/ApiError.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do no edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | import type { ApiRequestOptions } from './ApiRequestOptions' 6 | import type { ApiResult } from './ApiResult' 7 | 8 | export class ApiError extends Error { 9 | public readonly url: string 10 | public readonly status: number 11 | public readonly statusText: string 12 | public readonly body: any 13 | public readonly request: ApiRequestOptions 14 | 15 | constructor( 16 | request: ApiRequestOptions, 17 | response: ApiResult, 18 | message: string 19 | ) { 20 | super(message) 21 | 22 | this.name = 'ApiError' 23 | this.url = response.url 24 | this.status = response.status 25 | this.statusText = response.statusText 26 | this.body = response.body 27 | this.request = request 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /frontend/packages/types/api/core/ApiRequestOptions.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do no edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | export type ApiRequestOptions = { 6 | readonly method: 7 | | 'GET' 8 | | 'PUT' 9 | | 'POST' 10 | | 'DELETE' 11 | | 'OPTIONS' 12 | | 'HEAD' 13 | | 'PATCH' 14 | readonly url: string 15 | readonly path?: Record 16 | readonly cookies?: Record 17 | readonly headers?: Record 18 | readonly query?: Record 19 | readonly formData?: Record 20 | readonly body?: any 21 | readonly mediaType?: string 22 | readonly responseHeader?: string 23 | readonly errors?: Record 24 | } 25 | -------------------------------------------------------------------------------- /frontend/packages/types/api/core/ApiResult.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do no edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | export type ApiResult = { 6 | readonly url: string 7 | readonly ok: boolean 8 | readonly status: number 9 | readonly statusText: string 10 | readonly body: any 11 | } 12 | -------------------------------------------------------------------------------- /frontend/packages/types/api/core/BaseHttpRequest.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do no edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | import type { ApiRequestOptions } from './ApiRequestOptions' 6 | import type { CancelablePromise } from './CancelablePromise' 7 | import type { OpenAPIConfig } from './OpenAPI' 8 | 9 | export abstract class BaseHttpRequest { 10 | constructor(public readonly config: OpenAPIConfig) {} 11 | 12 | public abstract request(options: ApiRequestOptions): CancelablePromise 13 | } 14 | -------------------------------------------------------------------------------- /frontend/packages/types/api/core/CancelablePromise.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do no edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | export class CancelError extends Error { 6 | constructor(message: string) { 7 | super(message) 8 | this.name = 'CancelError' 9 | } 10 | 11 | public get isCancelled(): boolean { 12 | return true 13 | } 14 | } 15 | 16 | export interface OnCancel { 17 | readonly isResolved: boolean 18 | readonly isRejected: boolean 19 | readonly isCancelled: boolean 20 | 21 | (cancelHandler: () => void): void 22 | } 23 | 24 | export class CancelablePromise implements Promise { 25 | #isResolved: boolean 26 | #isRejected: boolean 27 | #isCancelled: boolean 28 | readonly #cancelHandlers: (() => void)[] 29 | readonly #promise: Promise 30 | #resolve?: (value: T | PromiseLike) => void 31 | #reject?: (reason?: any) => void 32 | 33 | constructor( 34 | executor: ( 35 | resolve: (value: T | PromiseLike) => void, 36 | reject: (reason?: any) => void, 37 | onCancel: OnCancel 38 | ) => void 39 | ) { 40 | this.#isResolved = false 41 | this.#isRejected = false 42 | this.#isCancelled = false 43 | this.#cancelHandlers = [] 44 | this.#promise = new Promise((resolve, reject) => { 45 | this.#resolve = resolve 46 | this.#reject = reject 47 | 48 | const onResolve = (value: T | PromiseLike): void => { 49 | if (this.#isResolved || this.#isRejected || this.#isCancelled) { 50 | return 51 | } 52 | this.#isResolved = true 53 | this.#resolve?.(value) 54 | } 55 | 56 | const onReject = (reason?: any): void => { 57 | if (this.#isResolved || this.#isRejected || this.#isCancelled) { 58 | return 59 | } 60 | this.#isRejected = true 61 | this.#reject?.(reason) 62 | } 63 | 64 | const onCancel = (cancelHandler: () => void): void => { 65 | if (this.#isResolved || this.#isRejected || this.#isCancelled) { 66 | return 67 | } 68 | this.#cancelHandlers.push(cancelHandler) 69 | } 70 | 71 | Object.defineProperty(onCancel, 'isResolved', { 72 | get: (): boolean => this.#isResolved 73 | }) 74 | 75 | Object.defineProperty(onCancel, 'isRejected', { 76 | get: (): boolean => this.#isRejected 77 | }) 78 | 79 | Object.defineProperty(onCancel, 'isCancelled', { 80 | get: (): boolean => this.#isCancelled 81 | }) 82 | 83 | return executor(onResolve, onReject, onCancel as OnCancel) 84 | }) 85 | } 86 | 87 | get [Symbol.toStringTag]() { 88 | return 'Cancellable Promise' 89 | } 90 | 91 | public then( 92 | onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, 93 | onRejected?: ((reason: any) => TResult2 | PromiseLike) | null 94 | ): Promise { 95 | return this.#promise.then(onFulfilled, onRejected) 96 | } 97 | 98 | public catch( 99 | onRejected?: ((reason: any) => TResult | PromiseLike) | null 100 | ): Promise { 101 | return this.#promise.catch(onRejected) 102 | } 103 | 104 | public finally(onFinally?: (() => void) | null): Promise { 105 | return this.#promise.finally(onFinally) 106 | } 107 | 108 | public cancel(): void { 109 | if (this.#isResolved || this.#isRejected || this.#isCancelled) { 110 | return 111 | } 112 | this.#isCancelled = true 113 | if (this.#cancelHandlers.length) { 114 | try { 115 | for (const cancelHandler of this.#cancelHandlers) { 116 | cancelHandler() 117 | } 118 | } catch (error) { 119 | console.warn('Cancellation threw an error', error) 120 | return 121 | } 122 | } 123 | this.#cancelHandlers.length = 0 124 | this.#reject?.(new CancelError('Request aborted')) 125 | } 126 | 127 | public get isCancelled(): boolean { 128 | return this.#isCancelled 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /frontend/packages/types/api/core/FetchHttpRequest.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do no edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | import type { ApiRequestOptions } from './ApiRequestOptions' 6 | import { BaseHttpRequest } from './BaseHttpRequest' 7 | import type { CancelablePromise } from './CancelablePromise' 8 | import type { OpenAPIConfig } from './OpenAPI' 9 | import { request as __request } from './request' 10 | 11 | export class FetchHttpRequest extends BaseHttpRequest { 12 | constructor(config: OpenAPIConfig) { 13 | super(config) 14 | } 15 | 16 | /** 17 | * Request method 18 | * @param options The request options from the service 19 | * @returns CancelablePromise 20 | * @throws ApiError 21 | */ 22 | public override request(options: ApiRequestOptions): CancelablePromise { 23 | return __request(this.config, options) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /frontend/packages/types/api/core/OpenAPI.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do no edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | import type { ApiRequestOptions } from './ApiRequestOptions' 6 | 7 | type Resolver = (options: ApiRequestOptions) => Promise 8 | type Headers = Record 9 | 10 | export type OpenAPIConfig = { 11 | BASE: string 12 | VERSION: string 13 | WITH_CREDENTIALS: boolean 14 | CREDENTIALS: 'include' | 'omit' | 'same-origin' 15 | TOKEN?: string | Resolver | undefined 16 | USERNAME?: string | Resolver | undefined 17 | PASSWORD?: string | Resolver | undefined 18 | HEADERS?: Headers | Resolver | undefined 19 | ENCODE_PATH?: ((path: string) => string) | undefined 20 | } 21 | 22 | export const OpenAPI: OpenAPIConfig = { 23 | BASE: '', 24 | VERSION: '0.0.0', 25 | WITH_CREDENTIALS: false, 26 | CREDENTIALS: 'include', 27 | TOKEN: undefined, 28 | USERNAME: undefined, 29 | PASSWORD: undefined, 30 | HEADERS: undefined, 31 | ENCODE_PATH: undefined 32 | } 33 | -------------------------------------------------------------------------------- /frontend/packages/types/api/core/request.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do no edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | import { ApiError } from './ApiError' 6 | import type { ApiRequestOptions } from './ApiRequestOptions' 7 | import type { ApiResult } from './ApiResult' 8 | import { CancelablePromise } from './CancelablePromise' 9 | import type { OnCancel } from './CancelablePromise' 10 | import type { OpenAPIConfig } from './OpenAPI' 11 | 12 | export const isDefined = ( 13 | value: T | null | undefined 14 | ): value is Exclude => { 15 | return value !== undefined && value !== null 16 | } 17 | 18 | export const isString = (value: any): value is string => { 19 | return typeof value === 'string' 20 | } 21 | 22 | export const isStringWithValue = (value: any): value is string => { 23 | return isString(value) && value !== '' 24 | } 25 | 26 | export const isBlob = (value: any): value is Blob => { 27 | return ( 28 | typeof value === 'object' && 29 | typeof value.type === 'string' && 30 | typeof value.stream === 'function' && 31 | typeof value.arrayBuffer === 'function' && 32 | typeof value.constructor === 'function' && 33 | typeof value.constructor.name === 'string' && 34 | /^(Blob|File)$/.test(value.constructor.name) && 35 | /^(Blob|File)$/.test(value[Symbol.toStringTag]) 36 | ) 37 | } 38 | 39 | export const isFormData = (value: any): value is FormData => { 40 | return value instanceof FormData 41 | } 42 | 43 | export const base64 = (str: string): string => { 44 | try { 45 | return btoa(str) 46 | } catch (err) { 47 | // @ts-ignore 48 | return Buffer.from(str).toString('base64') 49 | } 50 | } 51 | 52 | export const getQueryString = (params: Record): string => { 53 | const qs: string[] = [] 54 | 55 | const append = (key: string, value: any) => { 56 | qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`) 57 | } 58 | 59 | const process = (key: string, value: any) => { 60 | if (isDefined(value)) { 61 | if (Array.isArray(value)) { 62 | value.forEach((v) => { 63 | process(key, v) 64 | }) 65 | } else if (typeof value === 'object') { 66 | Object.entries(value).forEach(([k, v]) => { 67 | process(`${key}[${k}]`, v) 68 | }) 69 | } else { 70 | append(key, value) 71 | } 72 | } 73 | } 74 | 75 | Object.entries(params).forEach(([key, value]) => { 76 | process(key, value) 77 | }) 78 | 79 | if (qs.length > 0) { 80 | return `?${qs.join('&')}` 81 | } 82 | 83 | return '' 84 | } 85 | 86 | const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { 87 | const encoder = config.ENCODE_PATH || encodeURI 88 | 89 | const path = options.url 90 | .replace('{api-version}', config.VERSION) 91 | .replace(/{(.*?)}/g, (substring: string, group: string) => { 92 | if (options.path?.hasOwnProperty(group)) { 93 | return encoder(String(options.path[group])) 94 | } 95 | return substring 96 | }) 97 | 98 | const url = `${config.BASE}${path}` 99 | if (options.query) { 100 | return `${url}${getQueryString(options.query)}` 101 | } 102 | return url 103 | } 104 | 105 | export const getFormData = ( 106 | options: ApiRequestOptions 107 | ): FormData | undefined => { 108 | if (options.formData) { 109 | const formData = new FormData() 110 | 111 | const process = (key: string, value: any) => { 112 | if (isString(value) || isBlob(value)) { 113 | formData.append(key, value) 114 | } else { 115 | formData.append(key, JSON.stringify(value)) 116 | } 117 | } 118 | 119 | Object.entries(options.formData) 120 | .filter(([_, value]) => isDefined(value)) 121 | .forEach(([key, value]) => { 122 | if (Array.isArray(value)) { 123 | value.forEach((v) => process(key, v)) 124 | } else { 125 | process(key, value) 126 | } 127 | }) 128 | 129 | return formData 130 | } 131 | return undefined 132 | } 133 | 134 | type Resolver = (options: ApiRequestOptions) => Promise 135 | 136 | export const resolve = async ( 137 | options: ApiRequestOptions, 138 | resolver?: T | Resolver 139 | ): Promise => { 140 | if (typeof resolver === 'function') { 141 | return (resolver as Resolver)(options) 142 | } 143 | return resolver 144 | } 145 | 146 | export const getHeaders = async ( 147 | config: OpenAPIConfig, 148 | options: ApiRequestOptions 149 | ): Promise => { 150 | const token = await resolve(options, config.TOKEN) 151 | const username = await resolve(options, config.USERNAME) 152 | const password = await resolve(options, config.PASSWORD) 153 | const additionalHeaders = await resolve(options, config.HEADERS) 154 | 155 | const headers = Object.entries({ 156 | Accept: 'application/json', 157 | ...additionalHeaders, 158 | ...options.headers 159 | }) 160 | .filter(([_, value]) => isDefined(value)) 161 | .reduce( 162 | (headers, [key, value]) => ({ 163 | ...headers, 164 | [key]: String(value) 165 | }), 166 | {} as Record 167 | ) 168 | 169 | if (isStringWithValue(token)) { 170 | headers['Authorization'] = `Bearer ${token}` 171 | } 172 | 173 | if (isStringWithValue(username) && isStringWithValue(password)) { 174 | const credentials = base64(`${username}:${password}`) 175 | headers['Authorization'] = `Basic ${credentials}` 176 | } 177 | 178 | if (options.body) { 179 | if (options.mediaType) { 180 | headers['Content-Type'] = options.mediaType 181 | } else if (isBlob(options.body)) { 182 | headers['Content-Type'] = options.body.type || 'application/octet-stream' 183 | } else if (isString(options.body)) { 184 | headers['Content-Type'] = 'text/plain' 185 | } else if (!isFormData(options.body)) { 186 | headers['Content-Type'] = 'application/json' 187 | } 188 | } 189 | 190 | return new Headers(headers) 191 | } 192 | 193 | export const getRequestBody = (options: ApiRequestOptions): any => { 194 | if (options.body !== undefined) { 195 | if (options.mediaType?.includes('/json')) { 196 | return JSON.stringify(options.body) 197 | } else if ( 198 | isString(options.body) || 199 | isBlob(options.body) || 200 | isFormData(options.body) 201 | ) { 202 | return options.body 203 | } else { 204 | return JSON.stringify(options.body) 205 | } 206 | } 207 | return undefined 208 | } 209 | 210 | export const sendRequest = async ( 211 | config: OpenAPIConfig, 212 | options: ApiRequestOptions, 213 | url: string, 214 | body: any, 215 | formData: FormData | undefined, 216 | headers: Headers, 217 | onCancel: OnCancel 218 | ): Promise => { 219 | const controller = new AbortController() 220 | 221 | const request: RequestInit = { 222 | headers, 223 | body: body ?? formData, 224 | method: options.method, 225 | signal: controller.signal 226 | } 227 | 228 | if (config.WITH_CREDENTIALS) { 229 | request.credentials = config.CREDENTIALS 230 | } 231 | 232 | onCancel(() => controller.abort()) 233 | 234 | return await fetch(url, request) 235 | } 236 | 237 | export const getResponseHeader = ( 238 | response: Response, 239 | responseHeader?: string 240 | ): string | undefined => { 241 | if (responseHeader) { 242 | const content = response.headers.get(responseHeader) 243 | if (isString(content)) { 244 | return content 245 | } 246 | } 247 | return undefined 248 | } 249 | 250 | export const getResponseBody = async (response: Response): Promise => { 251 | if (response.status !== 204) { 252 | try { 253 | const contentType = response.headers.get('Content-Type') 254 | if (contentType) { 255 | const jsonTypes = ['application/json', 'application/problem+json'] 256 | const isJSON = jsonTypes.some((type) => 257 | contentType.toLowerCase().startsWith(type) 258 | ) 259 | if (isJSON) { 260 | return await response.json() 261 | } else { 262 | return await response.text() 263 | } 264 | } 265 | } catch (error) { 266 | console.error(error) 267 | } 268 | } 269 | return undefined 270 | } 271 | 272 | export const catchErrorCodes = ( 273 | options: ApiRequestOptions, 274 | result: ApiResult 275 | ): void => { 276 | const errors: Record = { 277 | 400: 'Bad Request', 278 | 401: 'Unauthorized', 279 | 403: 'Forbidden', 280 | 404: 'Not Found', 281 | 500: 'Internal Server Error', 282 | 502: 'Bad Gateway', 283 | 503: 'Service Unavailable', 284 | ...options.errors 285 | } 286 | 287 | const error = errors[result.status] 288 | if (error) { 289 | throw new ApiError(options, result, error) 290 | } 291 | 292 | if (!result.ok) { 293 | const errorStatus = result.status ?? 'unknown' 294 | const errorStatusText = result.statusText ?? 'unknown' 295 | const errorBody = (() => { 296 | try { 297 | return JSON.stringify(result.body, null, 2) 298 | } catch (e) { 299 | return undefined 300 | } 301 | })() 302 | 303 | throw new ApiError( 304 | options, 305 | result, 306 | `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` 307 | ) 308 | } 309 | } 310 | 311 | /** 312 | * Request method 313 | * @param config The OpenAPI configuration object 314 | * @param options The request options from the service 315 | * @returns CancelablePromise 316 | * @throws ApiError 317 | */ 318 | export const request = ( 319 | config: OpenAPIConfig, 320 | options: ApiRequestOptions 321 | ): CancelablePromise => { 322 | return new CancelablePromise(async (resolve, reject, onCancel) => { 323 | try { 324 | const url = getUrl(config, options) 325 | const formData = getFormData(options) 326 | const body = getRequestBody(options) 327 | const headers = await getHeaders(config, options) 328 | 329 | if (!onCancel.isCancelled) { 330 | const response = await sendRequest( 331 | config, 332 | options, 333 | url, 334 | body, 335 | formData, 336 | headers, 337 | onCancel 338 | ) 339 | const responseBody = await getResponseBody(response) 340 | const responseHeader = getResponseHeader( 341 | response, 342 | options.responseHeader 343 | ) 344 | 345 | const result: ApiResult = { 346 | url, 347 | ok: response.ok, 348 | status: response.status, 349 | statusText: response.statusText, 350 | body: responseHeader ?? responseBody 351 | } 352 | 353 | catchErrorCodes(options, result) 354 | 355 | resolve(result.body) 356 | } 357 | } catch (error) { 358 | reject(error) 359 | } 360 | }) 361 | } 362 | -------------------------------------------------------------------------------- /frontend/packages/types/api/index.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do no edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | export { ApiClient } from './ApiClient' 6 | 7 | export { ApiError } from './core/ApiError' 8 | export { BaseHttpRequest } from './core/BaseHttpRequest' 9 | export { CancelablePromise, CancelError } from './core/CancelablePromise' 10 | export { OpenAPI } from './core/OpenAPI' 11 | export type { OpenAPIConfig } from './core/OpenAPI' 12 | 13 | export type { PatchedUserCurrent } from './models/PatchedUserCurrent' 14 | export type { TokenObtainPair } from './models/TokenObtainPair' 15 | export type { TokenRefresh } from './models/TokenRefresh' 16 | export type { UserChangePassword } from './models/UserChangePassword' 17 | export type { UserChangePasswordError } from './models/UserChangePasswordError' 18 | export type { UserCreate } from './models/UserCreate' 19 | export type { UserCreateError } from './models/UserCreateError' 20 | export type { UserCurrent } from './models/UserCurrent' 21 | export type { UserCurrentError } from './models/UserCurrentError' 22 | 23 | export { SchemaService } from './services/SchemaService' 24 | export { TokenService } from './services/TokenService' 25 | export { UsersService } from './services/UsersService' 26 | -------------------------------------------------------------------------------- /frontend/packages/types/api/models/PatchedUserCurrent.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do no edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | 6 | export type PatchedUserCurrent = { 7 | /** 8 | * Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only. 9 | */ 10 | username?: string 11 | first_name?: string 12 | last_name?: string 13 | } 14 | -------------------------------------------------------------------------------- /frontend/packages/types/api/models/TokenObtainPair.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do no edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | 6 | export type TokenObtainPair = { 7 | username: string 8 | password: string 9 | readonly access: string 10 | readonly refresh: string 11 | } 12 | -------------------------------------------------------------------------------- /frontend/packages/types/api/models/TokenRefresh.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do no edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | 6 | export type TokenRefresh = { 7 | readonly access: string 8 | refresh: string 9 | } 10 | -------------------------------------------------------------------------------- /frontend/packages/types/api/models/UserChangePassword.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do no edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | 6 | export type UserChangePassword = { 7 | password: string 8 | password_new: string 9 | password_retype: string 10 | } 11 | -------------------------------------------------------------------------------- /frontend/packages/types/api/models/UserChangePasswordError.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do no edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | 6 | export type UserChangePasswordError = { 7 | password?: Array 8 | password_new?: Array 9 | password_retype?: Array 10 | } 11 | -------------------------------------------------------------------------------- /frontend/packages/types/api/models/UserCreate.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do no edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | 6 | export type UserCreate = { 7 | /** 8 | * Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only. 9 | */ 10 | username: string 11 | password: string 12 | password_retype: string 13 | } 14 | -------------------------------------------------------------------------------- /frontend/packages/types/api/models/UserCreateError.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do no edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | 6 | export type UserCreateError = { 7 | username?: Array 8 | password?: Array 9 | password_retype?: Array 10 | } 11 | -------------------------------------------------------------------------------- /frontend/packages/types/api/models/UserCurrent.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do no edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | 6 | export type UserCurrent = { 7 | /** 8 | * Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only. 9 | */ 10 | username: string 11 | first_name?: string 12 | last_name?: string 13 | } 14 | -------------------------------------------------------------------------------- /frontend/packages/types/api/models/UserCurrentError.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do no edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | 6 | export type UserCurrentError = { 7 | username?: Array 8 | first_name?: Array 9 | last_name?: Array 10 | } 11 | -------------------------------------------------------------------------------- /frontend/packages/types/api/services/SchemaService.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do no edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | import type { CancelablePromise } from '../core/CancelablePromise' 6 | import type { BaseHttpRequest } from '../core/BaseHttpRequest' 7 | 8 | export class SchemaService { 9 | constructor(public readonly httpRequest: BaseHttpRequest) {} 10 | 11 | /** 12 | * OpenApi3 schema for this API. Format can be selected via content negotiation. 13 | * 14 | * - YAML: application/vnd.oai.openapi 15 | * - JSON: application/vnd.oai.openapi+json 16 | * @param format 17 | * @param lang 18 | * @returns any 19 | * @throws ApiError 20 | */ 21 | public schemaRetrieve( 22 | format?: 'json' | 'yaml', 23 | lang?: 24 | | 'af' 25 | | 'ar' 26 | | 'ar-dz' 27 | | 'ast' 28 | | 'az' 29 | | 'be' 30 | | 'bg' 31 | | 'bn' 32 | | 'br' 33 | | 'bs' 34 | | 'ca' 35 | | 'ckb' 36 | | 'cs' 37 | | 'cy' 38 | | 'da' 39 | | 'de' 40 | | 'dsb' 41 | | 'el' 42 | | 'en' 43 | | 'en-au' 44 | | 'en-gb' 45 | | 'eo' 46 | | 'es' 47 | | 'es-ar' 48 | | 'es-co' 49 | | 'es-mx' 50 | | 'es-ni' 51 | | 'es-ve' 52 | | 'et' 53 | | 'eu' 54 | | 'fa' 55 | | 'fi' 56 | | 'fr' 57 | | 'fy' 58 | | 'ga' 59 | | 'gd' 60 | | 'gl' 61 | | 'he' 62 | | 'hi' 63 | | 'hr' 64 | | 'hsb' 65 | | 'hu' 66 | | 'hy' 67 | | 'ia' 68 | | 'id' 69 | | 'ig' 70 | | 'io' 71 | | 'is' 72 | | 'it' 73 | | 'ja' 74 | | 'ka' 75 | | 'kab' 76 | | 'kk' 77 | | 'km' 78 | | 'kn' 79 | | 'ko' 80 | | 'ky' 81 | | 'lb' 82 | | 'lt' 83 | | 'lv' 84 | | 'mk' 85 | | 'ml' 86 | | 'mn' 87 | | 'mr' 88 | | 'ms' 89 | | 'my' 90 | | 'nb' 91 | | 'ne' 92 | | 'nl' 93 | | 'nn' 94 | | 'os' 95 | | 'pa' 96 | | 'pl' 97 | | 'pt' 98 | | 'pt-br' 99 | | 'ro' 100 | | 'ru' 101 | | 'sk' 102 | | 'sl' 103 | | 'sq' 104 | | 'sr' 105 | | 'sr-latn' 106 | | 'sv' 107 | | 'sw' 108 | | 'ta' 109 | | 'te' 110 | | 'tg' 111 | | 'th' 112 | | 'tk' 113 | | 'tr' 114 | | 'tt' 115 | | 'udm' 116 | | 'ug' 117 | | 'uk' 118 | | 'ur' 119 | | 'uz' 120 | | 'vi' 121 | | 'zh-hans' 122 | | 'zh-hant' 123 | ): CancelablePromise> { 124 | return this.httpRequest.request({ 125 | method: 'GET', 126 | url: '/api/schema/', 127 | query: { 128 | format: format, 129 | lang: lang 130 | } 131 | }) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /frontend/packages/types/api/services/TokenService.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do no edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | import type { TokenObtainPair } from '../models/TokenObtainPair' 6 | import type { TokenRefresh } from '../models/TokenRefresh' 7 | 8 | import type { CancelablePromise } from '../core/CancelablePromise' 9 | import type { BaseHttpRequest } from '../core/BaseHttpRequest' 10 | 11 | export class TokenService { 12 | constructor(public readonly httpRequest: BaseHttpRequest) {} 13 | 14 | /** 15 | * Takes a set of user credentials and returns an access and refresh JSON web 16 | * token pair to prove the authentication of those credentials. 17 | * @param requestBody 18 | * @returns TokenObtainPair 19 | * @throws ApiError 20 | */ 21 | public tokenCreate( 22 | requestBody: TokenObtainPair 23 | ): CancelablePromise { 24 | return this.httpRequest.request({ 25 | method: 'POST', 26 | url: '/api/token/', 27 | body: requestBody, 28 | mediaType: 'application/json' 29 | }) 30 | } 31 | 32 | /** 33 | * Takes a refresh type JSON web token and returns an access type JSON web 34 | * token if the refresh token is valid. 35 | * @param requestBody 36 | * @returns TokenRefresh 37 | * @throws ApiError 38 | */ 39 | public tokenRefreshCreate( 40 | requestBody: TokenRefresh 41 | ): CancelablePromise { 42 | return this.httpRequest.request({ 43 | method: 'POST', 44 | url: '/api/token/refresh/', 45 | body: requestBody, 46 | mediaType: 'application/json' 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /frontend/packages/types/api/services/UsersService.ts: -------------------------------------------------------------------------------- 1 | /* generated using openapi-typescript-codegen -- do no edit */ 2 | /* istanbul ignore file */ 3 | /* tslint:disable */ 4 | /* eslint-disable */ 5 | import type { PatchedUserCurrent } from '../models/PatchedUserCurrent' 6 | import type { UserChangePassword } from '../models/UserChangePassword' 7 | import type { UserCreate } from '../models/UserCreate' 8 | import type { UserCurrent } from '../models/UserCurrent' 9 | 10 | import type { CancelablePromise } from '../core/CancelablePromise' 11 | import type { BaseHttpRequest } from '../core/BaseHttpRequest' 12 | 13 | export class UsersService { 14 | constructor(public readonly httpRequest: BaseHttpRequest) {} 15 | 16 | /** 17 | * @param requestBody 18 | * @returns UserCreate 19 | * @throws ApiError 20 | */ 21 | public usersCreate(requestBody: UserCreate): CancelablePromise { 22 | return this.httpRequest.request({ 23 | method: 'POST', 24 | url: '/api/users/', 25 | body: requestBody, 26 | mediaType: 'application/json' 27 | }) 28 | } 29 | 30 | /** 31 | * @param requestBody 32 | * @returns void 33 | * @throws ApiError 34 | */ 35 | public usersChangePasswordCreate( 36 | requestBody: UserChangePassword 37 | ): CancelablePromise { 38 | return this.httpRequest.request({ 39 | method: 'POST', 40 | url: '/api/users/change-password/', 41 | body: requestBody, 42 | mediaType: 'application/json' 43 | }) 44 | } 45 | 46 | /** 47 | * @returns void 48 | * @throws ApiError 49 | */ 50 | public usersDeleteAccountDestroy(): CancelablePromise { 51 | return this.httpRequest.request({ 52 | method: 'DELETE', 53 | url: '/api/users/delete-account/' 54 | }) 55 | } 56 | 57 | /** 58 | * @returns UserCurrent 59 | * @throws ApiError 60 | */ 61 | public usersMeRetrieve(): CancelablePromise { 62 | return this.httpRequest.request({ 63 | method: 'GET', 64 | url: '/api/users/me/' 65 | }) 66 | } 67 | 68 | /** 69 | * @param requestBody 70 | * @returns UserCurrent 71 | * @throws ApiError 72 | */ 73 | public usersMeUpdate( 74 | requestBody: UserCurrent 75 | ): CancelablePromise { 76 | return this.httpRequest.request({ 77 | method: 'PUT', 78 | url: '/api/users/me/', 79 | body: requestBody, 80 | mediaType: 'application/json' 81 | }) 82 | } 83 | 84 | /** 85 | * @param requestBody 86 | * @returns UserCurrent 87 | * @throws ApiError 88 | */ 89 | public usersMePartialUpdate( 90 | requestBody?: PatchedUserCurrent 91 | ): CancelablePromise { 92 | return this.httpRequest.request({ 93 | method: 'PATCH', 94 | url: '/api/users/me/', 95 | body: requestBody, 96 | mediaType: 'application/json' 97 | }) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /frontend/packages/types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@frontend/types", 3 | "version": "0.1.0", 4 | "private": true 5 | } 6 | -------------------------------------------------------------------------------- /frontend/packages/ui/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "globals.css", 9 | "baseColor": "gray", 10 | "cssVariables": true 11 | }, 12 | "iconLibrary": "lucide", 13 | "aliases": { 14 | "components": "@frontend/ui/components", 15 | "utils": "@frontend/ui/lib/utils", 16 | "hooks": "@frontend/ui/hooks", 17 | "lib": "@frontend/ui/lib", 18 | "ui": "@frontend/ui/components" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /frontend/packages/ui/forms/form-footer.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Link from 'next/link' 4 | import type React from 'react' 5 | 6 | export function FormFooter({ 7 | cta, 8 | link, 9 | title 10 | }: { 11 | cta: string 12 | link: string 13 | title: string 14 | }) { 15 | const actionLink = ( 16 | 20 | {title} 21 | 22 | ) 23 | 24 | return ( 25 |

26 | {cta} {actionLink} 27 |

28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /frontend/packages/ui/forms/form-header.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type React from 'react' 4 | 5 | export function FormHeader({ 6 | title, 7 | description 8 | }: { 9 | title: string 10 | description?: string 11 | }) { 12 | return ( 13 | <> 14 |

{title}

15 | 16 | {description &&

{description}

} 17 | 18 |
19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /frontend/packages/ui/forms/submit-field.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type React from 'react' 4 | import { twMerge } from 'tailwind-merge' 5 | 6 | export function SubmitField({ 7 | children, 8 | isLoading 9 | }: React.PropsWithChildren<{ 10 | isLoading?: boolean 11 | }>) { 12 | return ( 13 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /frontend/packages/ui/forms/text-field.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type React from 'react' 4 | import type { 5 | FieldValues, 6 | FormState, 7 | UseFormRegisterReturn 8 | } from 'react-hook-form' 9 | import { twMerge } from 'tailwind-merge' 10 | 11 | export function TextField({ 12 | type, 13 | label, 14 | placeholder, 15 | register, 16 | formState 17 | }: { 18 | type: 'text' | 'password' | 'number' 19 | label: string 20 | placeholder?: string 21 | register: UseFormRegisterReturn 22 | formState: FormState 23 | }): React.ReactElement { 24 | const hasError = formState.errors[register.name] 25 | 26 | return ( 27 | 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /frontend/packages/ui/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 | -------------------------------------------------------------------------------- /frontend/packages/ui/messages/error-message.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type React from 'react' 4 | import type { PropsWithChildren } from 'react' 5 | 6 | export function ErrorMessage({ children }: PropsWithChildren) { 7 | return ( 8 |
9 | {children} 10 |
11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /frontend/packages/ui/messages/success-message.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type React from 'react' 4 | import type { PropsWithChildren } from 'react' 5 | 6 | export function SuccessMessage({ children }: PropsWithChildren) { 7 | return ( 8 |
9 | {children} 10 |
11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /frontend/packages/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@frontend/ui", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@radix-ui/react-slot": "^1.1.1", 7 | "class-variance-authority": "^0.7.1", 8 | "clsx": "^2.1.1", 9 | "lucide-react": "^0.469.0", 10 | "next": "^15.2.4", 11 | "react-hook-form": "^7.54.2", 12 | "tailwind-merge": "^2.6.0", 13 | "tailwindcss-animate": "^1.0.7" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /frontend/packages/ui/postcss.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | module.exports = { 3 | plugins: { 4 | tailwindcss: {}, 5 | autoprefixer: {} 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/packages/ui/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 47.4% 11.2%; 9 | --muted: 210 40% 96.1%; 10 | --muted-foreground: 215.4 16.3% 46.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 222.2 47.4% 11.2%; 13 | --border: 214.3 31.8% 91.4%; 14 | --input: 214.3 31.8% 91.4%; 15 | --card: 0 0% 100%; 16 | --card-foreground: 222.2 47.4% 11.2%; 17 | --primary: 222.2 47.4% 11.2%; 18 | --primary-foreground: 210 40% 98%; 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | --accent: 210 40% 96.1%; 22 | --accent-foreground: 222.2 47.4% 11.2%; 23 | --destructive: 0 100% 50%; 24 | --destructive-foreground: 210 40% 98%; 25 | --ring: 215 20.2% 65.1%; 26 | --radius: 0.5rem; 27 | } 28 | 29 | .dark { 30 | --background: 224 71% 4%; 31 | --foreground: 213 31% 91%; 32 | --muted: 223 47% 11%; 33 | --muted-foreground: 215.4 16.3% 56.9%; 34 | --accent: 216 34% 17%; 35 | --accent-foreground: 210 40% 98%; 36 | --popover: 224 71% 4%; 37 | --popover-foreground: 215 20.2% 65.1%; 38 | --border: 216 34% 17%; 39 | --input: 216 34% 17%; 40 | --card: 224 71% 4%; 41 | --card-foreground: 213 31% 91%; 42 | --primary: 210 40% 98%; 43 | --primary-foreground: 222.2 47.4% 1.2%; 44 | --secondary: 222.2 47.4% 11.2%; 45 | --secondary-foreground: 210 40% 98%; 46 | --destructive: 0 63% 31%; 47 | --destructive-foreground: 210 40% 98%; 48 | --ring: 216 34% 17%; 49 | } 50 | } 51 | 52 | @layer base { 53 | * { 54 | @apply border-border; 55 | } 56 | body { 57 | @apply font-sans antialiased bg-background text-foreground; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /frontend/packages/ui/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | const config = { 4 | darkMode: ['class'], 5 | content: [ 6 | './components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './app/**/*.{js,ts,jsx,tsx,mdx}', 8 | '../../packages/ui/forms/**/*.{js,ts,jsx,tsx,mdx}', 9 | '../../packages/ui/messages/**/*.{js,ts,jsx,tsx,mdx}', 10 | '../../packages/ui/components/**/*.{js,ts,jsx,tsx,mdx}' 11 | ], 12 | theme: { 13 | extend: { 14 | colors: { 15 | border: 'hsl(var(--border))', 16 | input: 'hsl(var(--input))', 17 | ring: 'hsl(var(--ring))', 18 | background: 'hsl(var(--background))', 19 | foreground: 'hsl(var(--foreground))', 20 | primary: { 21 | DEFAULT: 'hsl(var(--primary))', 22 | foreground: 'hsl(var(--primary-foreground))' 23 | }, 24 | secondary: { 25 | DEFAULT: 'hsl(var(--secondary))', 26 | foreground: 'hsl(var(--secondary-foreground))' 27 | }, 28 | destructive: { 29 | DEFAULT: 'hsl(var(--destructive))', 30 | foreground: 'hsl(var(--destructive-foreground))' 31 | }, 32 | muted: { 33 | DEFAULT: 'hsl(var(--muted))', 34 | foreground: 'hsl(var(--muted-foreground))' 35 | }, 36 | accent: { 37 | DEFAULT: 'hsl(var(--accent))', 38 | foreground: 'hsl(var(--accent-foreground))' 39 | }, 40 | popover: { 41 | DEFAULT: 'hsl(var(--popover))', 42 | foreground: 'hsl(var(--popover-foreground))' 43 | }, 44 | card: { 45 | DEFAULT: 'hsl(var(--card))', 46 | foreground: 'hsl(var(--card-foreground))' 47 | } 48 | }, 49 | borderRadius: { 50 | lg: 'var(--radius)', 51 | md: 'calc(var(--radius) - 2px)', 52 | sm: 'calc(var(--radius) - 4px)' 53 | } 54 | } 55 | }, 56 | plugins: [require('tailwindcss-animate')] 57 | } satisfies Config 58 | 59 | export default config 60 | -------------------------------------------------------------------------------- /frontend/packages/ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@frontend/ui/*": ["./*"] 20 | } 21 | }, 22 | "include": ["."], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /frontend/pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'apps/*' 3 | - 'packages/*' 4 | --------------------------------------------------------------------------------