├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── Makefile ├── NinjaForum ├── __init__.py ├── api.py ├── asgi.py ├── pagination.py ├── settings.py ├── urls.py └── wsgi.py ├── README.md ├── conftest.py ├── fixtures ├── posts.json └── users.json ├── manage.py ├── poetry.lock ├── post ├── __init__.py ├── api.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── schemas.py └── tests.py ├── pyproject.toml ├── requirements.txt └── user ├── __init__.py ├── api.py ├── apps.py ├── migrations ├── 0001_initial.py ├── 0002_user_avatar.py └── __init__.py ├── models.py ├── schemas.py └── tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | 164 | .vscode/ 165 | media/ -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.6.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: check-added-large-files 7 | - id: check-ast 8 | - id: check-case-conflict 9 | - id: check-json 10 | - id: check-toml 11 | - id: check-yaml 12 | - id: end-of-file-fixer 13 | exclude: migrations/ 14 | 15 | - repo: https://github.com/astral-sh/ruff-pre-commit 16 | rev: v0.6.3 17 | hooks: 18 | - id: ruff 19 | args: 20 | - --fix 21 | - id: ruff-format 22 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12.5 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | migrations: 2 | python manage.py makemigrations 3 | 4 | migrate: 5 | python manage.py migrate 6 | 7 | run: 8 | python manage.py runserver 9 | -------------------------------------------------------------------------------- /NinjaForum/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kyomind/Django-Ninja-Tutorial/65572cc8fd5548eccd8a01abef18646a009e1f72/NinjaForum/__init__.py -------------------------------------------------------------------------------- /NinjaForum/api.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ObjectDoesNotExist, ValidationError 2 | from django.http import HttpRequest, HttpResponse 3 | from ninja import NinjaAPI 4 | from ninja.security import SessionAuth 5 | 6 | api = NinjaAPI( 7 | auth=SessionAuth(), # 設定全域認證 8 | title='忍者論壇 API', 9 | version='1.0', 10 | description='這是忍者論壇的 API 文件,供讀者參考', 11 | ) 12 | 13 | api.add_router(prefix='', router='user.api.router', tags=['User']) 14 | api.add_router(prefix='', router='post.api.router', tags=['Post']) 15 | 16 | 17 | @api.exception_handler(exc_class=ValidationError) 18 | def django_validation_error_handler( 19 | request: HttpRequest, exception: ValidationError 20 | ) -> HttpResponse: 21 | """ 22 | 處理 Django ValidationError 例外 23 | """ 24 | return api.create_response(request, {'detail': exception.message}, status=400) 25 | 26 | 27 | @api.exception_handler(exc_class=ObjectDoesNotExist) 28 | def object_does_not_exist_handler( 29 | request: HttpRequest, exception: ObjectDoesNotExist 30 | ) -> HttpResponse: 31 | """ 32 | 處理 Django ObjectDoesNotExist 例外 33 | """ 34 | return api.create_response(request, {'detail': '查無資料'}, status=404) 35 | -------------------------------------------------------------------------------- /NinjaForum/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for NinjaForum project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NinjaForum.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /NinjaForum/pagination.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from django.db.models.query import QuerySet 4 | from ninja import Field, Schema 5 | from ninja.pagination import PaginationBase 6 | 7 | 8 | class CustomPagination(PaginationBase): 9 | class Input(Schema): 10 | page: int = Field(1, ge=1) 11 | per_page: int = Field(10, ge=1, le=100) 12 | 13 | class Output(Schema): 14 | items: list 15 | page: int = Field(examples=[1]) 16 | per_page: int = Field(examples=[10]) 17 | total: int = Field(examples=[100]) 18 | 19 | def paginate_queryset( 20 | self, 21 | queryset: QuerySet, 22 | pagination: Input, 23 | **params: Any, 24 | ) -> dict[str, Any]: 25 | start = (pagination.page - 1) * pagination.per_page 26 | end = start + pagination.per_page 27 | return { 28 | 'items': queryset[start:end], 29 | 'page': pagination.page, 30 | 'per_page': pagination.per_page, 31 | 'total': queryset.count(), 32 | } 33 | -------------------------------------------------------------------------------- /NinjaForum/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for NinjaForum project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.2.15. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'django-insecure-o+2r-x=$s*be723daj3kimac$h6b7vkgozaxl0y(xzjve=gqwk' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'user', 41 | 'post', 42 | ] 43 | 44 | AUTH_USER_MODEL = 'user.User' # 變更預設 User 模型為自定義 User 模型 45 | 46 | 47 | MIDDLEWARE = [ 48 | 'django.middleware.security.SecurityMiddleware', 49 | 'django.contrib.sessions.middleware.SessionMiddleware', 50 | 'django.middleware.common.CommonMiddleware', 51 | 'django.middleware.csrf.CsrfViewMiddleware', 52 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 53 | 'django.contrib.messages.middleware.MessageMiddleware', 54 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 55 | ] 56 | 57 | ROOT_URLCONF = 'NinjaForum.urls' 58 | 59 | TEMPLATES = [ 60 | { 61 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 62 | 'DIRS': [], 63 | 'APP_DIRS': True, 64 | 'OPTIONS': { 65 | 'context_processors': [ 66 | 'django.template.context_processors.debug', 67 | 'django.template.context_processors.request', 68 | 'django.contrib.auth.context_processors.auth', 69 | 'django.contrib.messages.context_processors.messages', 70 | ], 71 | }, 72 | }, 73 | ] 74 | 75 | WSGI_APPLICATION = 'NinjaForum.wsgi.application' 76 | 77 | 78 | # Database 79 | # https://docs.djangoproject.com/en/4.2/ref/settings/#databases 80 | 81 | DATABASES = { 82 | 'default': { 83 | 'ENGINE': 'django.db.backends.sqlite3', 84 | 'NAME': BASE_DIR / 'db.sqlite3', 85 | } 86 | } 87 | 88 | 89 | # Password validation 90 | # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators 91 | 92 | AUTH_PASSWORD_VALIDATORS = [ 93 | { 94 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 95 | }, 96 | { 97 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 98 | }, 99 | { 100 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 101 | }, 102 | { 103 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 104 | }, 105 | ] 106 | 107 | 108 | # Internationalization 109 | # https://docs.djangoproject.com/en/4.2/topics/i18n/ 110 | 111 | LANGUAGE_CODE = 'en-us' 112 | 113 | TIME_ZONE = 'UTC' 114 | 115 | USE_I18N = True 116 | 117 | USE_TZ = True 118 | 119 | 120 | # Static files (CSS, JavaScript, Images) 121 | # https://docs.djangoproject.com/en/4.2/howto/static-files/ 122 | 123 | STATIC_URL = 'static/' 124 | 125 | # Default primary key field type 126 | # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field 127 | 128 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 129 | 130 | # 定義媒體檔案的 URL 路徑,當使用者上傳檔案時,Django 會透過此 URL 存取它們 131 | MEDIA_URL = '/media/' 132 | 133 | # 定義伺服器端儲存媒體檔案的實際路徑,所有上傳的媒體檔案將會儲存在該資料夾內 134 | MEDIA_ROOT = BASE_DIR / 'media' 135 | -------------------------------------------------------------------------------- /NinjaForum/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for NinjaForum project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/4.2/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | 18 | from django.conf import settings 19 | from django.conf.urls.static import static 20 | from django.contrib import admin 21 | from django.urls import path 22 | 23 | from NinjaForum.api import api 24 | 25 | urlpatterns = [ 26 | path('admin/', admin.site.urls), 27 | path('', api.urls), 28 | # 讓開發環境可以存取上傳的檔案,僅供開發環境使用 29 | ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 30 | -------------------------------------------------------------------------------- /NinjaForum/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for NinjaForum project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NinjaForum.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Kyo's Django Ninja Tutorial](https://i.imgur.com/5WLyxcH.png) 2 | 3 | [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-blue?labelColor=444&logo=pre-commit)](https://github.com/pre-commit/pre-commit) 4 | [![linting - Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 5 | [![code style - Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/format.json?labelColor=444)](https://github.com/astral-sh/ruff) 6 | [![Python](https://img.shields.io/badge/python-3.12-blue?labelColor=444&logo=python&logoColor=DDD)](https://www.python.org/) 7 | [![Django](https://img.shields.io/badge/django-4.2-forestgreen?labelColor=444&logo=django)](https://www.djangoproject.com/) 8 | [![Django Ninja](https://img.shields.io/badge/django--ninja-1.3-forestgreen?labelColor=444&&logoColor=DDD)](https://django-ninja.dev/) 9 | 10 | # Django Ninja 系列教學 11 | 12 | >賀!本系列榮獲 Python 組「**優選**」獎 🏆([得獎名單](https://ithelp.ithome.com.tw/2024ironman/reward)) 13 | 14 | [2024 iThome 鐵人賽](https://ithelp.ithome.com.tw/2024ironman/)參賽作品:《[Django 忍法帖——Django Ninja 入門指南](https://ithelp.ithome.com.tw/users/20167825/ironman/7451)》 15 | 16 | 面向初學者的 Django Ninja 系列教學,旨在幫助你學習 Django Ninja,建立高效且現代的 API。 17 | 18 | 透過**範例專案**和 **30 篇文章**,你將逐步掌握 Django Ninja 的核心概念與使用方法,並了解它和 Django REST framework 的主要差異。 19 | 20 | 每個章節均包含具體的程式碼範例,讓你能**邊看邊學**,化理論為實踐。 21 | 22 | > [!NOTE] 23 | > 如果你對 Django、Django REST framework 教學與 Python 開發文章感興趣,歡迎參考我的姐妹倉庫:[Django-Tutorial](https://github.com/kyomind/Django-Tutorial)。 24 | 25 | ## 第一章:導讀與 Django Ninja 介紹 26 | 27 | - [卷 1:系列導讀 × 目標讀者](https://blog.kyomind.tw/django-ninja-01/) 28 | - [卷 2:架構與章節導覽](https://blog.kyomind.tw/django-ninja-02/) 29 | - [卷 3:Django Ninja 介紹——與 Django REST framework 主要區別](https://blog.kyomind.tw/django-ninja-03/) 30 | 31 | ## 第二章:範例專案與環境設定 32 | 33 | - [卷 4:API 範例專案介紹](https://blog.kyomind.tw/django-ninja-04/) 34 | - [卷 5:Python 現代開發工具介紹](https://blog.kyomind.tw/django-ninja-05/) 35 | - [卷 6:環境設定 × 如何使用本專案](https://blog.kyomind.tw/django-ninja-06/) 36 | 37 | ## 第三章:Django Ninja 基本功 38 | 39 | ### 第一節:路由(Routers) 40 | 41 | - [卷 7:路由(上)傳統 Django 路由做法](https://blog.kyomind.tw/django-ninja-07/) 42 | - [卷 8:路由(下)Django Ninja 路由設定](https://blog.kyomind.tw/django-ninja-08/) 43 | 44 | ### 第二節:請求(HTTP Request) 45 | 46 | - [卷 9:請求(一)Django Ninja 處理 HTTP 請求](https://blog.kyomind.tw/django-ninja-09/) 47 | - [卷 10:請求(二)路徑參數 - Path Parameters](https://blog.kyomind.tw/django-ninja-10/) 48 | - [卷 11:請求(三)查詢參數 - Query Parameters](https://blog.kyomind.tw/django-ninja-11/) 49 | - [卷 12:請求(四)Request Body 與 Schema 介紹](https://blog.kyomind.tw/django-ninja-12/) 50 | 51 | ### 第三節:回應(HTTP Response) 52 | 53 | - [卷 13:回應(一)Django Ninja 處理 HTTP 回應](https://blog.kyomind.tw/django-ninja-13/) 54 | - [卷 14:回應(二)用 Schema 建立巢狀結構回應](https://blog.kyomind.tw/django-ninja-14/) 55 | - [卷 15:回應(三)為何不用 ModelSchema?——相比 DRF,我更偏愛 Django Ninja 的理由](https://blog.kyomind.tw/django-ninja-15/) 56 | - [卷 16:回應(四)Resolver 方法——欄位資料格式化](https://blog.kyomind.tw/django-ninja-16/) 57 | 58 | ## 第四章:API 文件 59 | 60 | - [卷 17:API 文件(上)Django Ninja 文件實踐指南](https://blog.kyomind.tw/django-ninja-17/) 61 | - [卷 18:API 文件(下)Pydantic Field 設定範例與預設值](https://blog.kyomind.tw/django-ninja-18/) 62 | 63 | ## 第五章:資料驗證與錯誤處理 64 | 65 | - [卷 19:資料驗證(上)Pydantic 單一欄位驗證](https://blog.kyomind.tw/django-ninja-19/) 66 | - [卷 20:資料驗證(下)Pydantic 跨欄位驗證](https://blog.kyomind.tw/django-ninja-20/) 67 | - [卷 21:錯誤處理(上)HttpError 與自定義 HTTP 回應](https://blog.kyomind.tw/django-ninja-21/) 68 | - [卷 22:錯誤處理(下)全域錯誤處理——使用 Exception Handlers](https://blog.kyomind.tw/django-ninja-22/) 69 | 70 | ## 第六章,API 進階功能 71 | - [卷 23:檔案上傳——Django UploadedFile 介紹](https://blog.kyomind.tw/django-ninja-23/) 72 | - [卷 24:分頁(上)Django Ninja 的內建分頁器](https://blog.kyomind.tw/django-ninja-24/) 73 | - [卷 25:分頁(下)自定義分頁類別](https://blog.kyomind.tw/django-ninja-25/) 74 | - [卷 26:資料查詢與過濾(上)FilterSchema 介紹](https://blog.kyomind.tw/django-ninja-26/) 75 | - [卷 27:資料查詢與過濾(下)FilterSchema 多欄位查詢](https://blog.kyomind.tw/django-ninja-27/) 76 | 77 | ## 第七章:身分認證與單元測試 78 | 79 | - [卷 28:身分認證——Session 認證與全域設定](https://blog.kyomind.tw/django-ninja-28/) 80 | - [卷 29:單元測試——使用 Test Client 與 pytest 測試 API](https://blog.kyomind.tw/django-ninja-29/) 81 | 82 | ## 第八章:系列回顧與完賽心得 83 | 84 | - [卷 30:系列回顧與完賽心得](https://blog.kyomind.tw/django-ninja-30/) 85 | - [iThome 鐵人賽寫作攻略——新手必看指南](https://blog.kyomind.tw/ithome-ironman-tips/) 86 | 87 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.test import Client 3 | 4 | from user.models import User 5 | 6 | 7 | @pytest.fixture(scope='session') 8 | def client() -> Client: 9 | """ 10 | 返回 Django 測試用的 client 11 | 12 | 補充說明: 13 | pytest-django 本來就有提供 client fixture,這裡是為了示範如何自訂 client fixture 14 | 請留意這個 client fixture 是 session scope,也就是整個測試過程只會建立一次 15 | """ 16 | return Client() 17 | 18 | 19 | @pytest.fixture 20 | def user() -> User: 21 | """ 22 | 建立使用者並返回 23 | """ 24 | return User.objects.create_user( 25 | username='testuser', email='testuser@example.com', password='testpassword123' 26 | ) 27 | 28 | 29 | @pytest.fixture 30 | def authenticated_client(client: Client, user: User) -> Client: 31 | """ 32 | 登入並返回已認證的 client 33 | """ 34 | response = client.post( 35 | '/users/login/', 36 | {'username': 'testuser', 'password': 'testpassword123'}, 37 | content_type='application/json', 38 | ) 39 | assert response.status_code == 200 40 | # 設定登入後的 cookies 41 | client.cookies.update(response.cookies) 42 | return client 43 | -------------------------------------------------------------------------------- /fixtures/posts.json: -------------------------------------------------------------------------------- 1 | [{"model": "post.post", "pk": 1, "fields": {"title": "232332", "content": "HELLO", "author": 1, "created_at": "2024-09-11T10:25:56.823Z", "updated_at": "2024-09-11T10:25:56.823Z"}}, {"model": "post.post", "pk": 2, "fields": {"title": "Alice's Django Ninja Post 1", "content": "Alice's Django Ninja Post 1 content", "author": 1, "created_at": "2024-09-12T02:28:16.801Z", "updated_at": "2024-09-12T02:28:16.801Z"}}, {"model": "post.post", "pk": 3, "fields": {"title": "Alice's Django Ninja Post 2", "content": "Alice's Django Ninja Post 2 content", "author": 1, "created_at": "2024-09-12T02:28:16.803Z", "updated_at": "2024-09-12T02:28:16.803Z"}}, {"model": "post.post", "pk": 4, "fields": {"title": "Alice's Django Ninja Post 3", "content": "Alice's Django Ninja Post 3 content", "author": 1, "created_at": "2024-09-12T02:28:16.804Z", "updated_at": "2024-09-12T02:28:16.804Z"}}, {"model": "post.post", "pk": 5, "fields": {"title": "Alice's Django Ninja Post 4", "content": "Alice's Django Ninja Post 4 content", "author": 1, "created_at": "2024-09-12T02:28:16.805Z", "updated_at": "2024-09-12T02:28:16.805Z"}}, {"model": "post.post", "pk": 6, "fields": {"title": "Alice's Django Ninja Post 5", "content": "Alice's Django Ninja Post 5 content", "author": 1, "created_at": "2024-09-12T02:28:16.806Z", "updated_at": "2024-09-12T02:28:16.806Z"}}, {"model": "post.post", "pk": 7, "fields": {"title": "Alice's Django Ninja Post 6", "content": "Alice's Django Ninja Post 6 content", "author": 1, "created_at": "2024-09-12T02:28:16.807Z", "updated_at": "2024-09-12T02:28:16.807Z"}}, {"model": "post.post", "pk": 8, "fields": {"title": "Alice's Django Ninja Post 7", "content": "Alice's Django Ninja Post 7 content", "author": 1, "created_at": "2024-09-12T02:28:16.808Z", "updated_at": "2024-09-12T02:28:16.808Z"}}, {"model": "post.post", "pk": 9, "fields": {"title": "Alice's Django Ninja Post 8", "content": "Alice's Django Ninja Post 8 content", "author": 1, "created_at": "2024-09-12T02:28:16.808Z", "updated_at": "2024-09-12T02:28:16.808Z"}}, {"model": "post.post", "pk": 10, "fields": {"title": "Alice's Django Ninja Post 9", "content": "Alice's Django Ninja Post 9 content", "author": 1, "created_at": "2024-09-12T02:28:16.809Z", "updated_at": "2024-09-12T02:28:16.809Z"}}, {"model": "post.post", "pk": 11, "fields": {"title": "Alice's Django Ninja Post 10", "content": "Alice's Django Ninja Post 10 content", "author": 1, "created_at": "2024-09-12T02:28:16.810Z", "updated_at": "2024-09-12T02:28:16.810Z"}}, {"model": "post.post", "pk": 12, "fields": {"title": "Alice's Django Ninja Post 11", "content": "Alice's Django Ninja Post 11 content", "author": 1, "created_at": "2024-09-12T02:28:16.811Z", "updated_at": "2024-09-12T02:28:16.811Z"}}, {"model": "post.post", "pk": 13, "fields": {"title": "Alice's Django Ninja Post 12", "content": "Alice's Django Ninja Post 12 content", "author": 1, "created_at": "2024-09-12T02:28:16.812Z", "updated_at": "2024-09-12T02:28:16.812Z"}}, {"model": "post.post", "pk": 14, "fields": {"title": "Alice's Django Ninja Post 13", "content": "Alice's Django Ninja Post 13 content", "author": 1, "created_at": "2024-09-12T02:28:16.813Z", "updated_at": "2024-09-12T02:28:16.813Z"}}, {"model": "post.post", "pk": 15, "fields": {"title": "Alice's Django Ninja Post 14", "content": "Alice's Django Ninja Post 14 content", "author": 1, "created_at": "2024-09-12T02:28:16.814Z", "updated_at": "2024-09-12T02:28:16.814Z"}}, {"model": "post.post", "pk": 16, "fields": {"title": "Alice's Django Ninja Post 15", "content": "Alice's Django Ninja Post 15 content", "author": 1, "created_at": "2024-09-12T02:28:16.815Z", "updated_at": "2024-09-12T02:28:16.815Z"}}, {"model": "post.post", "pk": 17, "fields": {"title": "Alice's Django Ninja Post 16", "content": "Alice's Django Ninja Post 16 content", "author": 1, "created_at": "2024-09-12T02:28:16.815Z", "updated_at": "2024-09-12T02:28:16.815Z"}}, {"model": "post.post", "pk": 18, "fields": {"title": "Alice's Django Ninja Post 17", "content": "Alice's Django Ninja Post 17 content", "author": 1, "created_at": "2024-09-12T02:28:16.816Z", "updated_at": "2024-09-12T02:28:16.816Z"}}, {"model": "post.post", "pk": 19, "fields": {"title": "Alice's Django Ninja Post 18", "content": "Alice's Django Ninja Post 18 content", "author": 1, "created_at": "2024-09-12T02:28:16.817Z", "updated_at": "2024-09-12T02:28:16.817Z"}}, {"model": "post.post", "pk": 20, "fields": {"title": "Alice's Django Ninja Post 19", "content": "Alice's Django Ninja Post 19 content", "author": 1, "created_at": "2024-09-12T02:28:16.817Z", "updated_at": "2024-09-12T02:28:16.817Z"}}, {"model": "post.post", "pk": 21, "fields": {"title": "Alice's Django Ninja Post 20", "content": "Alice's Django Ninja Post 20 content", "author": 1, "created_at": "2024-09-12T02:28:16.818Z", "updated_at": "2024-09-12T02:28:16.818Z"}}, {"model": "post.post", "pk": 22, "fields": {"title": "Alice's Django Ninja Post 21", "content": "Alice's Django Ninja Post 21 content", "author": 1, "created_at": "2024-09-12T02:28:16.818Z", "updated_at": "2024-09-12T02:28:16.818Z"}}, {"model": "post.post", "pk": 23, "fields": {"title": "Alice's Django Ninja Post 22", "content": "Alice's Django Ninja Post 22 content", "author": 1, "created_at": "2024-09-12T02:28:16.819Z", "updated_at": "2024-09-12T02:28:16.819Z"}}, {"model": "post.post", "pk": 24, "fields": {"title": "Alice's Django Ninja Post 23", "content": "Alice's Django Ninja Post 23 content", "author": 1, "created_at": "2024-09-12T02:28:16.819Z", "updated_at": "2024-09-12T02:28:16.819Z"}}, {"model": "post.post", "pk": 25, "fields": {"title": "Alice's Django Ninja Post 24", "content": "Alice's Django Ninja Post 24 content", "author": 1, "created_at": "2024-09-12T02:28:16.820Z", "updated_at": "2024-09-12T02:28:16.820Z"}}, {"model": "post.post", "pk": 26, "fields": {"title": "Alice's Django Ninja Post 25", "content": "Alice's Django Ninja Post 25 content", "author": 1, "created_at": "2024-09-12T02:28:16.820Z", "updated_at": "2024-09-12T02:28:16.820Z"}}, {"model": "post.post", "pk": 27, "fields": {"title": "Alice's Django Ninja Post 26", "content": "Alice's Django Ninja Post 26 content", "author": 1, "created_at": "2024-09-12T02:28:16.821Z", "updated_at": "2024-09-12T02:28:16.821Z"}}, {"model": "post.post", "pk": 28, "fields": {"title": "Alice's Django Ninja Post 27", "content": "Alice's Django Ninja Post 27 content", "author": 1, "created_at": "2024-09-12T02:28:16.821Z", "updated_at": "2024-09-12T02:28:16.821Z"}}, {"model": "post.post", "pk": 29, "fields": {"title": "Alice's Django Ninja Post 28", "content": "Alice's Django Ninja Post 28 content", "author": 1, "created_at": "2024-09-12T02:28:16.822Z", "updated_at": "2024-09-12T02:28:16.822Z"}}, {"model": "post.post", "pk": 30, "fields": {"title": "Alice's Django Ninja Post 29", "content": "Alice's Django Ninja Post 29 content", "author": 1, "created_at": "2024-09-12T02:28:16.822Z", "updated_at": "2024-09-12T02:28:16.822Z"}}, {"model": "post.post", "pk": 31, "fields": {"title": "Alice's Django Ninja Post 30", "content": "Alice's Django Ninja Post 30 content", "author": 1, "created_at": "2024-09-12T02:28:16.823Z", "updated_at": "2024-09-12T02:28:16.823Z"}}, {"model": "post.post", "pk": 32, "fields": {"title": "Bob's Django Ninja Post 1", "content": "Bob's Django Ninja Post 1 content", "author": 2, "created_at": "2024-09-12T02:28:51.081Z", "updated_at": "2024-09-12T02:28:51.082Z"}}, {"model": "post.post", "pk": 33, "fields": {"title": "Bob's Django Ninja Post 2", "content": "Bob's Django Ninja Post 2 content", "author": 2, "created_at": "2024-09-12T02:28:51.084Z", "updated_at": "2024-09-12T02:28:51.084Z"}}, {"model": "post.post", "pk": 34, "fields": {"title": "Bob's Django Ninja Post 3", "content": "Bob's Django Ninja Post 3 content", "author": 2, "created_at": "2024-09-12T02:28:51.085Z", "updated_at": "2024-09-12T02:28:51.085Z"}}, {"model": "post.post", "pk": 35, "fields": {"title": "Bob's Django Ninja Post 4", "content": "Bob's Django Ninja Post 4 content", "author": 2, "created_at": "2024-09-12T02:28:51.086Z", "updated_at": "2024-09-12T02:28:51.086Z"}}, {"model": "post.post", "pk": 36, "fields": {"title": "Bob's Django Ninja Post 5", "content": "Bob's Django Ninja Post 5 content", "author": 2, "created_at": "2024-09-12T02:28:51.087Z", "updated_at": "2024-09-12T02:28:51.087Z"}}, {"model": "post.post", "pk": 37, "fields": {"title": "Bob's Django Ninja Post 6", "content": "Bob's Django Ninja Post 6 content", "author": 2, "created_at": "2024-09-12T02:28:51.088Z", "updated_at": "2024-09-12T02:28:51.088Z"}}, {"model": "post.post", "pk": 38, "fields": {"title": "Bob's Django Ninja Post 7", "content": "Bob's Django Ninja Post 7 content", "author": 2, "created_at": "2024-09-12T02:28:51.089Z", "updated_at": "2024-09-12T02:28:51.089Z"}}, {"model": "post.post", "pk": 39, "fields": {"title": "Bob's Django Ninja Post 8", "content": "Bob's Django Ninja Post 8 content", "author": 2, "created_at": "2024-09-12T02:28:51.090Z", "updated_at": "2024-09-12T02:28:51.090Z"}}, {"model": "post.post", "pk": 40, "fields": {"title": "Bob's Django Ninja Post 9", "content": "Bob's Django Ninja Post 9 content", "author": 2, "created_at": "2024-09-12T02:28:51.091Z", "updated_at": "2024-09-12T02:28:51.091Z"}}, {"model": "post.post", "pk": 41, "fields": {"title": "Bob's Django Ninja Post 10", "content": "Bob's Django Ninja Post 10 content", "author": 2, "created_at": "2024-09-12T02:28:51.092Z", "updated_at": "2024-09-12T02:28:51.092Z"}}, {"model": "post.post", "pk": 42, "fields": {"title": "Bob's Django Ninja Post 11", "content": "Bob's Django Ninja Post 11 content", "author": 2, "created_at": "2024-09-12T02:28:51.093Z", "updated_at": "2024-09-12T02:28:51.093Z"}}, {"model": "post.post", "pk": 43, "fields": {"title": "Bob's Django Ninja Post 12", "content": "Bob's Django Ninja Post 12 content", "author": 2, "created_at": "2024-09-12T02:28:51.094Z", "updated_at": "2024-09-12T02:28:51.094Z"}}, {"model": "post.post", "pk": 44, "fields": {"title": "Bob's Django Ninja Post 13", "content": "Bob's Django Ninja Post 13 content", "author": 2, "created_at": "2024-09-12T02:28:51.094Z", "updated_at": "2024-09-12T02:28:51.094Z"}}, {"model": "post.post", "pk": 45, "fields": {"title": "Bob's Django Ninja Post 14", "content": "Bob's Django Ninja Post 14 content", "author": 2, "created_at": "2024-09-12T02:28:51.095Z", "updated_at": "2024-09-12T02:28:51.095Z"}}, {"model": "post.post", "pk": 46, "fields": {"title": "Bob's Django Ninja Post 15", "content": "Bob's Django Ninja Post 15 content", "author": 2, "created_at": "2024-09-12T02:28:51.096Z", "updated_at": "2024-09-12T02:28:51.096Z"}}, {"model": "post.post", "pk": 47, "fields": {"title": "Bob's Django Ninja Post 16", "content": "Bob's Django Ninja Post 16 content", "author": 2, "created_at": "2024-09-12T02:28:51.097Z", "updated_at": "2024-09-12T02:28:51.097Z"}}, {"model": "post.post", "pk": 48, "fields": {"title": "Bob's Django Ninja Post 17", "content": "Bob's Django Ninja Post 17 content", "author": 2, "created_at": "2024-09-12T02:28:51.098Z", "updated_at": "2024-09-12T02:28:51.098Z"}}, {"model": "post.post", "pk": 49, "fields": {"title": "Bob's Django Ninja Post 18", "content": "Bob's Django Ninja Post 18 content", "author": 2, "created_at": "2024-09-12T02:28:51.098Z", "updated_at": "2024-09-12T02:28:51.098Z"}}, {"model": "post.post", "pk": 50, "fields": {"title": "Bob's Django Ninja Post 19", "content": "Bob's Django Ninja Post 19 content", "author": 2, "created_at": "2024-09-12T02:28:51.099Z", "updated_at": "2024-09-12T02:28:51.099Z"}}, {"model": "post.post", "pk": 51, "fields": {"title": "Bob's Django Ninja Post 20", "content": "Bob's Django Ninja Post 20 content", "author": 2, "created_at": "2024-09-12T02:28:51.100Z", "updated_at": "2024-09-12T02:28:51.100Z"}}, {"model": "post.post", "pk": 52, "fields": {"title": "Bob's Django Ninja Post 21", "content": "Bob's Django Ninja Post 21 content", "author": 2, "created_at": "2024-09-12T02:28:51.100Z", "updated_at": "2024-09-12T02:28:51.100Z"}}, {"model": "post.post", "pk": 53, "fields": {"title": "Bob's Django Ninja Post 22", "content": "Bob's Django Ninja Post 22 content", "author": 2, "created_at": "2024-09-12T02:28:51.101Z", "updated_at": "2024-09-12T02:28:51.101Z"}}, {"model": "post.post", "pk": 54, "fields": {"title": "Bob's Django Ninja Post 23", "content": "Bob's Django Ninja Post 23 content", "author": 2, "created_at": "2024-09-12T02:28:51.102Z", "updated_at": "2024-09-12T02:28:51.102Z"}}, {"model": "post.post", "pk": 55, "fields": {"title": "Bob's Django Ninja Post 24", "content": "Bob's Django Ninja Post 24 content", "author": 2, "created_at": "2024-09-12T02:28:51.103Z", "updated_at": "2024-09-12T02:28:51.103Z"}}, {"model": "post.post", "pk": 56, "fields": {"title": "Bob's Django Ninja Post 25", "content": "Bob's Django Ninja Post 25 content", "author": 2, "created_at": "2024-09-12T02:28:51.103Z", "updated_at": "2024-09-12T02:28:51.103Z"}}, {"model": "post.post", "pk": 57, "fields": {"title": "Bob's Django Ninja Post 26", "content": "Bob's Django Ninja Post 26 content", "author": 2, "created_at": "2024-09-12T02:28:51.104Z", "updated_at": "2024-09-12T02:28:51.104Z"}}, {"model": "post.post", "pk": 58, "fields": {"title": "Bob's Django Ninja Post 27", "content": "Bob's Django Ninja Post 27 content", "author": 2, "created_at": "2024-09-12T02:28:51.105Z", "updated_at": "2024-09-12T02:28:51.105Z"}}, {"model": "post.post", "pk": 59, "fields": {"title": "Bob's Django Ninja Post 28", "content": "Bob's Django Ninja Post 28 content", "author": 2, "created_at": "2024-09-12T02:28:51.105Z", "updated_at": "2024-09-12T02:28:51.105Z"}}, {"model": "post.post", "pk": 60, "fields": {"title": "Bob's Django Ninja Post 29", "content": "Bob's Django Ninja Post 29 content", "author": 2, "created_at": "2024-09-12T02:28:51.106Z", "updated_at": "2024-09-12T02:28:51.106Z"}}, {"model": "post.post", "pk": 61, "fields": {"title": "Bob's Django Ninja Post 30", "content": "Bob's Django Ninja Post 30 content", "author": 2, "created_at": "2024-09-12T02:28:51.106Z", "updated_at": "2024-09-12T02:28:51.106Z"}}] -------------------------------------------------------------------------------- /fixtures/users.json: -------------------------------------------------------------------------------- 1 | [{"model": "user.user", "pk": 1, "fields": {"password": "pbkdf2_sha256$600000$MPJDlsosB43WxNd5lMfIV8$1j8ingLBBQX6GlW3lBkHFBAz2IFkLWee2E3H1kkmwyI=", "last_login": null, "is_superuser": false, "username": "Alice", "first_name": "", "last_name": "", "is_staff": false, "is_active": true, "date_joined": "2024-09-11T10:11:03.843Z", "email": "alice@example.com", "bio": null, "groups": [], "user_permissions": []}}, {"model": "user.user", "pk": 2, "fields": {"password": "pbkdf2_sha256$600000$aE5qmp6MDksoboclbuCAEi$9kFIHbuIfA8CKC70IqeIgg5w9CDU4eGYFyBjtZnVlhg=", "last_login": null, "is_superuser": false, "username": "Bob", "first_name": "", "last_name": "", "is_staff": false, "is_active": true, "date_joined": "2024-09-11T10:11:04.014Z", "email": "bob@example.com", "bio": null, "groups": [], "user_permissions": []}}] -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NinjaForum.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "annotated-types" 5 | version = "0.7.0" 6 | description = "Reusable constraint types to use with typing.Annotated" 7 | category = "main" 8 | optional = false 9 | python-versions = ">=3.8" 10 | files = [ 11 | {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, 12 | {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, 13 | ] 14 | 15 | [[package]] 16 | name = "asgiref" 17 | version = "3.8.1" 18 | description = "ASGI specs, helper code, and adapters" 19 | category = "main" 20 | optional = false 21 | python-versions = ">=3.8" 22 | files = [ 23 | {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, 24 | {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, 25 | ] 26 | 27 | [package.extras] 28 | tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] 29 | 30 | [[package]] 31 | name = "cfgv" 32 | version = "3.4.0" 33 | description = "Validate configuration and produce human readable error messages." 34 | category = "dev" 35 | optional = false 36 | python-versions = ">=3.8" 37 | files = [ 38 | {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, 39 | {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, 40 | ] 41 | 42 | [[package]] 43 | name = "colorama" 44 | version = "0.4.6" 45 | description = "Cross-platform colored terminal text." 46 | category = "dev" 47 | optional = false 48 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 49 | files = [ 50 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 51 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 52 | ] 53 | 54 | [[package]] 55 | name = "distlib" 56 | version = "0.3.8" 57 | description = "Distribution utilities" 58 | category = "dev" 59 | optional = false 60 | python-versions = "*" 61 | files = [ 62 | {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, 63 | {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, 64 | ] 65 | 66 | [[package]] 67 | name = "django" 68 | version = "4.2.16" 69 | description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." 70 | category = "main" 71 | optional = false 72 | python-versions = ">=3.8" 73 | files = [ 74 | {file = "Django-4.2.16-py3-none-any.whl", hash = "sha256:1ddc333a16fc139fd253035a1606bb24261951bbc3a6ca256717fa06cc41a898"}, 75 | {file = "Django-4.2.16.tar.gz", hash = "sha256:6f1616c2786c408ce86ab7e10f792b8f15742f7b7b7460243929cb371e7f1dad"}, 76 | ] 77 | 78 | [package.dependencies] 79 | asgiref = ">=3.6.0,<4" 80 | sqlparse = ">=0.3.1" 81 | tzdata = {version = "*", markers = "sys_platform == \"win32\""} 82 | 83 | [package.extras] 84 | argon2 = ["argon2-cffi (>=19.1.0)"] 85 | bcrypt = ["bcrypt"] 86 | 87 | [[package]] 88 | name = "django-ninja" 89 | version = "1.3.0" 90 | description = "Django Ninja - Fast Django REST framework" 91 | category = "main" 92 | optional = false 93 | python-versions = ">=3.7" 94 | files = [ 95 | {file = "django_ninja-1.3.0-py3-none-any.whl", hash = "sha256:f58096b6c767d1403dfd6c49743f82d780d7b9688d9302ecab316ac1fa6131bb"}, 96 | {file = "django_ninja-1.3.0.tar.gz", hash = "sha256:5b320e2dc0f41a6032bfa7e1ebc33559ae1e911a426f0c6be6674a50b20819be"}, 97 | ] 98 | 99 | [package.dependencies] 100 | Django = ">=3.1" 101 | pydantic = ">=2.0,<3.0.0" 102 | 103 | [package.extras] 104 | dev = ["pre-commit"] 105 | doc = ["markdown-include", "mkdocs", "mkdocs-material", "mkdocstrings"] 106 | test = ["django-stubs", "mypy (==1.7.1)", "psycopg2-binary", "pytest", "pytest-asyncio", "pytest-cov", "pytest-django", "ruff (==0.5.7)"] 107 | 108 | [[package]] 109 | name = "django-stubs" 110 | version = "5.0.4" 111 | description = "Mypy stubs for Django" 112 | category = "dev" 113 | optional = false 114 | python-versions = ">=3.8" 115 | files = [ 116 | {file = "django_stubs-5.0.4-py3-none-any.whl", hash = "sha256:c2502f5ecbae50c68f9a86d52b5b2447d8648fd205036dad0ccb41e19a445927"}, 117 | {file = "django_stubs-5.0.4.tar.gz", hash = "sha256:78e3764488fdfd2695f12502136548ec22f8d4b1780541a835042b8238d11514"}, 118 | ] 119 | 120 | [package.dependencies] 121 | asgiref = "*" 122 | django = "*" 123 | django-stubs-ext = ">=5.0.4" 124 | types-PyYAML = "*" 125 | typing-extensions = ">=4.11.0" 126 | 127 | [package.extras] 128 | compatible-mypy = ["mypy (>=1.11.0,<1.12.0)"] 129 | oracle = ["oracledb"] 130 | redis = ["redis"] 131 | 132 | [[package]] 133 | name = "django-stubs-ext" 134 | version = "5.0.4" 135 | description = "Monkey-patching and extensions for django-stubs" 136 | category = "dev" 137 | optional = false 138 | python-versions = ">=3.8" 139 | files = [ 140 | {file = "django_stubs_ext-5.0.4-py3-none-any.whl", hash = "sha256:910cbaff3d1e8e806a5c27d5ddd4088535aae8371ea921b7fd680fdfa5f14e30"}, 141 | {file = "django_stubs_ext-5.0.4.tar.gz", hash = "sha256:85da065224204774208be29c7d02b4482d5a69218a728465c2fbe41725fdc819"}, 142 | ] 143 | 144 | [package.dependencies] 145 | django = "*" 146 | typing-extensions = "*" 147 | 148 | [[package]] 149 | name = "filelock" 150 | version = "3.15.4" 151 | description = "A platform independent file lock." 152 | category = "dev" 153 | optional = false 154 | python-versions = ">=3.8" 155 | files = [ 156 | {file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"}, 157 | {file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"}, 158 | ] 159 | 160 | [package.extras] 161 | docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] 162 | testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"] 163 | typing = ["typing-extensions (>=4.8)"] 164 | 165 | [[package]] 166 | name = "identify" 167 | version = "2.6.0" 168 | description = "File identification library for Python" 169 | category = "dev" 170 | optional = false 171 | python-versions = ">=3.8" 172 | files = [ 173 | {file = "identify-2.6.0-py2.py3-none-any.whl", hash = "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0"}, 174 | {file = "identify-2.6.0.tar.gz", hash = "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf"}, 175 | ] 176 | 177 | [package.extras] 178 | license = ["ukkonen"] 179 | 180 | [[package]] 181 | name = "iniconfig" 182 | version = "2.0.0" 183 | description = "brain-dead simple config-ini parsing" 184 | category = "dev" 185 | optional = false 186 | python-versions = ">=3.7" 187 | files = [ 188 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 189 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 190 | ] 191 | 192 | [[package]] 193 | name = "mypy" 194 | version = "1.11.2" 195 | description = "Optional static typing for Python" 196 | category = "dev" 197 | optional = false 198 | python-versions = ">=3.8" 199 | files = [ 200 | {file = "mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a"}, 201 | {file = "mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef"}, 202 | {file = "mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383"}, 203 | {file = "mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8"}, 204 | {file = "mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7"}, 205 | {file = "mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385"}, 206 | {file = "mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca"}, 207 | {file = "mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104"}, 208 | {file = "mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4"}, 209 | {file = "mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6"}, 210 | {file = "mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318"}, 211 | {file = "mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36"}, 212 | {file = "mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987"}, 213 | {file = "mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca"}, 214 | {file = "mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70"}, 215 | {file = "mypy-1.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b"}, 216 | {file = "mypy-1.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86"}, 217 | {file = "mypy-1.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce"}, 218 | {file = "mypy-1.11.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1"}, 219 | {file = "mypy-1.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b"}, 220 | {file = "mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6"}, 221 | {file = "mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70"}, 222 | {file = "mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d"}, 223 | {file = "mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d"}, 224 | {file = "mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24"}, 225 | {file = "mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12"}, 226 | {file = "mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79"}, 227 | ] 228 | 229 | [package.dependencies] 230 | mypy-extensions = ">=1.0.0" 231 | typing-extensions = ">=4.6.0" 232 | 233 | [package.extras] 234 | dmypy = ["psutil (>=4.0)"] 235 | install-types = ["pip"] 236 | mypyc = ["setuptools (>=50)"] 237 | reports = ["lxml"] 238 | 239 | [[package]] 240 | name = "mypy-extensions" 241 | version = "1.0.0" 242 | description = "Type system extensions for programs checked with the mypy type checker." 243 | category = "dev" 244 | optional = false 245 | python-versions = ">=3.5" 246 | files = [ 247 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 248 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 249 | ] 250 | 251 | [[package]] 252 | name = "nodeenv" 253 | version = "1.9.1" 254 | description = "Node.js virtual environment builder" 255 | category = "dev" 256 | optional = false 257 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 258 | files = [ 259 | {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, 260 | {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, 261 | ] 262 | 263 | [[package]] 264 | name = "packaging" 265 | version = "24.1" 266 | description = "Core utilities for Python packages" 267 | category = "dev" 268 | optional = false 269 | python-versions = ">=3.8" 270 | files = [ 271 | {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, 272 | {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, 273 | ] 274 | 275 | [[package]] 276 | name = "pillow" 277 | version = "10.4.0" 278 | description = "Python Imaging Library (Fork)" 279 | category = "main" 280 | optional = false 281 | python-versions = ">=3.8" 282 | files = [ 283 | {file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"}, 284 | {file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"}, 285 | {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856"}, 286 | {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f"}, 287 | {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b"}, 288 | {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc"}, 289 | {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e"}, 290 | {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46"}, 291 | {file = "pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984"}, 292 | {file = "pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141"}, 293 | {file = "pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1"}, 294 | {file = "pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c"}, 295 | {file = "pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be"}, 296 | {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3"}, 297 | {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6"}, 298 | {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe"}, 299 | {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319"}, 300 | {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d"}, 301 | {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696"}, 302 | {file = "pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496"}, 303 | {file = "pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91"}, 304 | {file = "pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22"}, 305 | {file = "pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94"}, 306 | {file = "pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597"}, 307 | {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80"}, 308 | {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca"}, 309 | {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef"}, 310 | {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a"}, 311 | {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b"}, 312 | {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9"}, 313 | {file = "pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42"}, 314 | {file = "pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a"}, 315 | {file = "pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9"}, 316 | {file = "pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3"}, 317 | {file = "pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb"}, 318 | {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70"}, 319 | {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be"}, 320 | {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0"}, 321 | {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc"}, 322 | {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a"}, 323 | {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309"}, 324 | {file = "pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060"}, 325 | {file = "pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea"}, 326 | {file = "pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d"}, 327 | {file = "pillow-10.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736"}, 328 | {file = "pillow-10.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b"}, 329 | {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2"}, 330 | {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680"}, 331 | {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b"}, 332 | {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd"}, 333 | {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84"}, 334 | {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0"}, 335 | {file = "pillow-10.4.0-cp38-cp38-win32.whl", hash = "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e"}, 336 | {file = "pillow-10.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab"}, 337 | {file = "pillow-10.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d"}, 338 | {file = "pillow-10.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b"}, 339 | {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd"}, 340 | {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126"}, 341 | {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b"}, 342 | {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c"}, 343 | {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1"}, 344 | {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df"}, 345 | {file = "pillow-10.4.0-cp39-cp39-win32.whl", hash = "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef"}, 346 | {file = "pillow-10.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5"}, 347 | {file = "pillow-10.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e"}, 348 | {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4"}, 349 | {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da"}, 350 | {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026"}, 351 | {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e"}, 352 | {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5"}, 353 | {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885"}, 354 | {file = "pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5"}, 355 | {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b"}, 356 | {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908"}, 357 | {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b"}, 358 | {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8"}, 359 | {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a"}, 360 | {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27"}, 361 | {file = "pillow-10.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3"}, 362 | {file = "pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06"}, 363 | ] 364 | 365 | [package.extras] 366 | docs = ["furo", "olefile", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] 367 | fpx = ["olefile"] 368 | mic = ["olefile"] 369 | tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] 370 | typing = ["typing-extensions"] 371 | xmp = ["defusedxml"] 372 | 373 | [[package]] 374 | name = "platformdirs" 375 | version = "4.2.2" 376 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." 377 | category = "dev" 378 | optional = false 379 | python-versions = ">=3.8" 380 | files = [ 381 | {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, 382 | {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, 383 | ] 384 | 385 | [package.extras] 386 | docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] 387 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] 388 | type = ["mypy (>=1.8)"] 389 | 390 | [[package]] 391 | name = "pluggy" 392 | version = "1.5.0" 393 | description = "plugin and hook calling mechanisms for python" 394 | category = "dev" 395 | optional = false 396 | python-versions = ">=3.8" 397 | files = [ 398 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 399 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 400 | ] 401 | 402 | [package.extras] 403 | dev = ["pre-commit", "tox"] 404 | testing = ["pytest", "pytest-benchmark"] 405 | 406 | [[package]] 407 | name = "pre-commit" 408 | version = "3.8.0" 409 | description = "A framework for managing and maintaining multi-language pre-commit hooks." 410 | category = "dev" 411 | optional = false 412 | python-versions = ">=3.9" 413 | files = [ 414 | {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, 415 | {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, 416 | ] 417 | 418 | [package.dependencies] 419 | cfgv = ">=2.0.0" 420 | identify = ">=1.0.0" 421 | nodeenv = ">=0.11.1" 422 | pyyaml = ">=5.1" 423 | virtualenv = ">=20.10.0" 424 | 425 | [[package]] 426 | name = "pydantic" 427 | version = "2.8.2" 428 | description = "Data validation using Python type hints" 429 | category = "main" 430 | optional = false 431 | python-versions = ">=3.8" 432 | files = [ 433 | {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, 434 | {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, 435 | ] 436 | 437 | [package.dependencies] 438 | annotated-types = ">=0.4.0" 439 | pydantic-core = "2.20.1" 440 | typing-extensions = [ 441 | {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, 442 | {version = ">=4.6.1", markers = "python_version < \"3.13\""}, 443 | ] 444 | 445 | [package.extras] 446 | email = ["email-validator (>=2.0.0)"] 447 | 448 | [[package]] 449 | name = "pydantic-core" 450 | version = "2.20.1" 451 | description = "Core functionality for Pydantic validation and serialization" 452 | category = "main" 453 | optional = false 454 | python-versions = ">=3.8" 455 | files = [ 456 | {file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"}, 457 | {file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"}, 458 | {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"}, 459 | {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"}, 460 | {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"}, 461 | {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"}, 462 | {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"}, 463 | {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"}, 464 | {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"}, 465 | {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"}, 466 | {file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"}, 467 | {file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"}, 468 | {file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312"}, 469 | {file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88"}, 470 | {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc"}, 471 | {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43"}, 472 | {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6"}, 473 | {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121"}, 474 | {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1"}, 475 | {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b"}, 476 | {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27"}, 477 | {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b"}, 478 | {file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a"}, 479 | {file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2"}, 480 | {file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"}, 481 | {file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"}, 482 | {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"}, 483 | {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"}, 484 | {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"}, 485 | {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"}, 486 | {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"}, 487 | {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"}, 488 | {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"}, 489 | {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"}, 490 | {file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"}, 491 | {file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"}, 492 | {file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"}, 493 | {file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"}, 494 | {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"}, 495 | {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"}, 496 | {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"}, 497 | {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"}, 498 | {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"}, 499 | {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"}, 500 | {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"}, 501 | {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"}, 502 | {file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"}, 503 | {file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"}, 504 | {file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91"}, 505 | {file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b"}, 506 | {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a"}, 507 | {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f"}, 508 | {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad"}, 509 | {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c"}, 510 | {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598"}, 511 | {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd"}, 512 | {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa"}, 513 | {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987"}, 514 | {file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a"}, 515 | {file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434"}, 516 | {file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c"}, 517 | {file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6"}, 518 | {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2"}, 519 | {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a"}, 520 | {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611"}, 521 | {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b"}, 522 | {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006"}, 523 | {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1"}, 524 | {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09"}, 525 | {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab"}, 526 | {file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2"}, 527 | {file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669"}, 528 | {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"}, 529 | {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"}, 530 | {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"}, 531 | {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"}, 532 | {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"}, 533 | {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"}, 534 | {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"}, 535 | {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"}, 536 | {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"}, 537 | {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"}, 538 | {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"}, 539 | {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"}, 540 | {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"}, 541 | {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"}, 542 | {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"}, 543 | {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"}, 544 | {file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"}, 545 | ] 546 | 547 | [package.dependencies] 548 | typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" 549 | 550 | [[package]] 551 | name = "pytest" 552 | version = "8.3.2" 553 | description = "pytest: simple powerful testing with Python" 554 | category = "dev" 555 | optional = false 556 | python-versions = ">=3.8" 557 | files = [ 558 | {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, 559 | {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, 560 | ] 561 | 562 | [package.dependencies] 563 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 564 | iniconfig = "*" 565 | packaging = "*" 566 | pluggy = ">=1.5,<2" 567 | 568 | [package.extras] 569 | dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 570 | 571 | [[package]] 572 | name = "pytest-django" 573 | version = "4.9.0" 574 | description = "A Django plugin for pytest." 575 | category = "dev" 576 | optional = false 577 | python-versions = ">=3.8" 578 | files = [ 579 | {file = "pytest_django-4.9.0-py3-none-any.whl", hash = "sha256:1d83692cb39188682dbb419ff0393867e9904094a549a7d38a3154d5731b2b99"}, 580 | {file = "pytest_django-4.9.0.tar.gz", hash = "sha256:8bf7bc358c9ae6f6fc51b6cebb190fe20212196e6807121f11bd6a3b03428314"}, 581 | ] 582 | 583 | [package.dependencies] 584 | pytest = ">=7.0.0" 585 | 586 | [package.extras] 587 | docs = ["sphinx", "sphinx-rtd-theme"] 588 | testing = ["Django", "django-configurations (>=2.0)"] 589 | 590 | [[package]] 591 | name = "pyyaml" 592 | version = "6.0.2" 593 | description = "YAML parser and emitter for Python" 594 | category = "dev" 595 | optional = false 596 | python-versions = ">=3.8" 597 | files = [ 598 | {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, 599 | {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, 600 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, 601 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, 602 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, 603 | {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, 604 | {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, 605 | {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, 606 | {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, 607 | {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, 608 | {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, 609 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, 610 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, 611 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, 612 | {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, 613 | {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, 614 | {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, 615 | {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, 616 | {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, 617 | {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, 618 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, 619 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, 620 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, 621 | {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, 622 | {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, 623 | {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, 624 | {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, 625 | {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, 626 | {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, 627 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, 628 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, 629 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, 630 | {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, 631 | {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, 632 | {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, 633 | {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, 634 | {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, 635 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, 636 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, 637 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, 638 | {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, 639 | {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, 640 | {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, 641 | {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, 642 | {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, 643 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, 644 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, 645 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, 646 | {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, 647 | {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, 648 | {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, 649 | {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, 650 | {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, 651 | ] 652 | 653 | [[package]] 654 | name = "ruff" 655 | version = "0.6.3" 656 | description = "An extremely fast Python linter and code formatter, written in Rust." 657 | category = "dev" 658 | optional = false 659 | python-versions = ">=3.7" 660 | files = [ 661 | {file = "ruff-0.6.3-py3-none-linux_armv6l.whl", hash = "sha256:97f58fda4e309382ad30ede7f30e2791d70dd29ea17f41970119f55bdb7a45c3"}, 662 | {file = "ruff-0.6.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3b061e49b5cf3a297b4d1c27ac5587954ccb4ff601160d3d6b2f70b1622194dc"}, 663 | {file = "ruff-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:34e2824a13bb8c668c71c1760a6ac7d795ccbd8d38ff4a0d8471fdb15de910b1"}, 664 | {file = "ruff-0.6.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bddfbb8d63c460f4b4128b6a506e7052bad4d6f3ff607ebbb41b0aa19c2770d1"}, 665 | {file = "ruff-0.6.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ced3eeb44df75353e08ab3b6a9e113b5f3f996bea48d4f7c027bc528ba87b672"}, 666 | {file = "ruff-0.6.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47021dff5445d549be954eb275156dfd7c37222acc1e8014311badcb9b4ec8c1"}, 667 | {file = "ruff-0.6.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d7bd20dc07cebd68cc8bc7b3f5ada6d637f42d947c85264f94b0d1cd9d87384"}, 668 | {file = "ruff-0.6.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:500f166d03fc6d0e61c8e40a3ff853fa8a43d938f5d14c183c612df1b0d6c58a"}, 669 | {file = "ruff-0.6.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42844ff678f9b976366b262fa2d1d1a3fe76f6e145bd92c84e27d172e3c34500"}, 670 | {file = "ruff-0.6.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70452a10eb2d66549de8e75f89ae82462159855e983ddff91bc0bce6511d0470"}, 671 | {file = "ruff-0.6.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65a533235ed55f767d1fc62193a21cbf9e3329cf26d427b800fdeacfb77d296f"}, 672 | {file = "ruff-0.6.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2e2c23cef30dc3cbe9cc5d04f2899e7f5e478c40d2e0a633513ad081f7361b5"}, 673 | {file = "ruff-0.6.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d8a136aa7d228975a6aee3dd8bea9b28e2b43e9444aa678fb62aeb1956ff2351"}, 674 | {file = "ruff-0.6.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f92fe93bc72e262b7b3f2bba9879897e2d58a989b4714ba6a5a7273e842ad2f8"}, 675 | {file = "ruff-0.6.3-py3-none-win32.whl", hash = "sha256:7a62d3b5b0d7f9143d94893f8ba43aa5a5c51a0ffc4a401aa97a81ed76930521"}, 676 | {file = "ruff-0.6.3-py3-none-win_amd64.whl", hash = "sha256:746af39356fee2b89aada06c7376e1aa274a23493d7016059c3a72e3b296befb"}, 677 | {file = "ruff-0.6.3-py3-none-win_arm64.whl", hash = "sha256:14a9528a8b70ccc7a847637c29e56fd1f9183a9db743bbc5b8e0c4ad60592a82"}, 678 | {file = "ruff-0.6.3.tar.gz", hash = "sha256:183b99e9edd1ef63be34a3b51fee0a9f4ab95add123dbf89a71f7b1f0c991983"}, 679 | ] 680 | 681 | [[package]] 682 | name = "sqlparse" 683 | version = "0.5.1" 684 | description = "A non-validating SQL parser." 685 | category = "main" 686 | optional = false 687 | python-versions = ">=3.8" 688 | files = [ 689 | {file = "sqlparse-0.5.1-py3-none-any.whl", hash = "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4"}, 690 | {file = "sqlparse-0.5.1.tar.gz", hash = "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e"}, 691 | ] 692 | 693 | [package.extras] 694 | dev = ["build", "hatch"] 695 | doc = ["sphinx"] 696 | 697 | [[package]] 698 | name = "types-pyyaml" 699 | version = "6.0.12.20240808" 700 | description = "Typing stubs for PyYAML" 701 | category = "dev" 702 | optional = false 703 | python-versions = ">=3.8" 704 | files = [ 705 | {file = "types-PyYAML-6.0.12.20240808.tar.gz", hash = "sha256:b8f76ddbd7f65440a8bda5526a9607e4c7a322dc2f8e1a8c405644f9a6f4b9af"}, 706 | {file = "types_PyYAML-6.0.12.20240808-py3-none-any.whl", hash = "sha256:deda34c5c655265fc517b546c902aa6eed2ef8d3e921e4765fe606fe2afe8d35"}, 707 | ] 708 | 709 | [[package]] 710 | name = "typing-extensions" 711 | version = "4.12.2" 712 | description = "Backported and Experimental Type Hints for Python 3.8+" 713 | category = "main" 714 | optional = false 715 | python-versions = ">=3.8" 716 | files = [ 717 | {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, 718 | {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, 719 | ] 720 | 721 | [[package]] 722 | name = "tzdata" 723 | version = "2024.1" 724 | description = "Provider of IANA time zone data" 725 | category = "main" 726 | optional = false 727 | python-versions = ">=2" 728 | files = [ 729 | {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, 730 | {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, 731 | ] 732 | 733 | [[package]] 734 | name = "virtualenv" 735 | version = "20.26.3" 736 | description = "Virtual Python Environment builder" 737 | category = "dev" 738 | optional = false 739 | python-versions = ">=3.7" 740 | files = [ 741 | {file = "virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589"}, 742 | {file = "virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a"}, 743 | ] 744 | 745 | [package.dependencies] 746 | distlib = ">=0.3.7,<1" 747 | filelock = ">=3.12.2,<4" 748 | platformdirs = ">=3.9.1,<5" 749 | 750 | [package.extras] 751 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] 752 | test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] 753 | 754 | [metadata] 755 | lock-version = "2.0" 756 | python-versions = "^3.12" 757 | content-hash = "ba66c3cb0e6a5084c73644bbf1117fa8d242dfe1d9458040d5b6711018963bf7" 758 | -------------------------------------------------------------------------------- /post/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kyomind/Django-Ninja-Tutorial/65572cc8fd5548eccd8a01abef18646a009e1f72/post/__init__.py -------------------------------------------------------------------------------- /post/api.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | from django.http import HttpRequest 3 | from ninja import Query, Router 4 | from ninja.pagination import paginate 5 | 6 | from NinjaForum.pagination import CustomPagination 7 | from post.models import Post 8 | from post.schemas import CreatePostRequest, PostFilterSchema, PostListResponse, PostResponse 9 | 10 | router = Router() 11 | 12 | 13 | @router.get(path='/posts/', response=list[PostListResponse], summary='取得文章列表') 14 | @paginate(CustomPagination) 15 | def get_posts( 16 | request: HttpRequest, 17 | filters: PostFilterSchema = Query(), 18 | ) -> QuerySet[Post]: 19 | """ 20 | 取得文章列表 21 | """ 22 | posts = Post.objects.select_related('author') 23 | posts = filters.filter(posts) 24 | return posts 25 | 26 | 27 | @router.get(path='/posts/{int:post_id}/', response=PostResponse, summary='取得單一文章資訊') 28 | def get_post(request: HttpRequest, post_id: int) -> Post: 29 | """ 30 | 取得單一文章資訊 31 | """ 32 | post = Post.objects.get(id=post_id) 33 | return post 34 | 35 | 36 | @router.post(path='/posts/', response={201: dict}, summary='新增文章') 37 | def create_post(request: HttpRequest, payload: CreatePostRequest) -> tuple[int, dict]: 38 | """ 39 | 新增文章 40 | """ 41 | post = Post.objects.create( 42 | title=payload.title, 43 | content=payload.content, 44 | author_id=payload.user_id, 45 | ) 46 | return 201, {'id': post.id, 'title': post.title} 47 | -------------------------------------------------------------------------------- /post/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class PostConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'post' 7 | -------------------------------------------------------------------------------- /post/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.16 on 2024-09-08 09:01 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Post', 19 | fields=[ 20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('title', models.CharField(max_length=255)), 22 | ('content', models.TextField()), 23 | ('created_at', models.DateTimeField(auto_now_add=True)), 24 | ('updated_at', models.DateTimeField(auto_now=True)), 25 | ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 26 | ], 27 | ), 28 | migrations.CreateModel( 29 | name='Comment', 30 | fields=[ 31 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 32 | ('content', models.TextField()), 33 | ('created_at', models.DateTimeField(auto_now_add=True)), 34 | ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 35 | ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='post.post')), 36 | ], 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /post/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kyomind/Django-Ninja-Tutorial/65572cc8fd5548eccd8a01abef18646a009e1f72/post/migrations/__init__.py -------------------------------------------------------------------------------- /post/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from user.models import User 4 | 5 | 6 | # 文章 (Post) 模型 7 | class Post(models.Model): 8 | title = models.CharField(max_length=255) # 文章標題 9 | content = models.TextField() # 文章內容 10 | author = models.ForeignKey(User, on_delete=models.CASCADE) # 關聯到自定義 User 模型 11 | created_at = models.DateTimeField(auto_now_add=True) # 發文時間 12 | updated_at = models.DateTimeField(auto_now=True) # 更新時間 13 | 14 | def __str__(self) -> str: 15 | return self.title 16 | 17 | 18 | # 評論 (Comment) 模型 19 | class Comment(models.Model): 20 | content = models.TextField() # 評論內容 21 | post = models.ForeignKey(Post, related_name='comments', on_delete=models.CASCADE) # 所屬文章 22 | author = models.ForeignKey(User, on_delete=models.CASCADE) # 評論者 (關聯到自定義 User 模型) 23 | created_at = models.DateTimeField(auto_now_add=True) # 評論時間 24 | 25 | def __str__(self) -> str: 26 | return f'Comment by {self.author.username} on {self.post.title}' 27 | -------------------------------------------------------------------------------- /post/schemas.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Self 3 | 4 | from ninja import Field, FilterSchema, Schema 5 | from pydantic import model_validator 6 | from django.core.exceptions import ValidationError 7 | 8 | from post.models import Post 9 | 10 | 11 | class CreatePostRequest(Schema): 12 | title: str 13 | content: str 14 | user_id: int 15 | 16 | 17 | class _AuthorInfo(Schema): 18 | id: int = Field(examples=[1]) 19 | username: str = Field(examples=['Alice']) 20 | email: str = Field(examples=['alice@exapmple.com']) 21 | 22 | 23 | class PostResponse(Schema): 24 | id: int = Field(examples=[1]) 25 | title: str = Field(examples=['Ninja is awesome!']) 26 | content: str = Field(examples=['This is my first post.']) 27 | author: _AuthorInfo 28 | created_at: datetime = Field(examples=['2021-01-01T00:00:00Z']) 29 | updated_at: datetime = Field(examples=['2021-01-01T00:00:00Z']) 30 | 31 | @staticmethod 32 | def resolve_created_at(obj: Post) -> str: 33 | return obj.created_at.strftime('%Y-%m-%dT%H:%M:%SZ') 34 | 35 | @staticmethod 36 | def resolve_updated_at(obj: Post) -> str: 37 | return obj.updated_at.strftime('%Y-%m-%dT%H:%M:%SZ') 38 | 39 | 40 | class PostListResponse(Schema): 41 | id: int 42 | title: str 43 | created_at: datetime 44 | author_name: str = Field(alias='author.username') 45 | 46 | @staticmethod 47 | def resolve_created_at(obj: Post) -> str: 48 | return obj.created_at.strftime('%Y-%m-%dT%H:%M:%SZ') 49 | 50 | 51 | class PostFilterSchema(FilterSchema): 52 | query: str | None = Field( 53 | None, 54 | q=['title__icontains', 'author__username__icontains'], 55 | min_length=2, 56 | max_length=10, 57 | ) 58 | start_date: str | None = Field(None, q='created_at__gte') 59 | end_date: str | None = Field(None, q='created_at__lte') 60 | 61 | @model_validator(mode='after') 62 | def check_date_range(self) -> Self: 63 | # 如果開始日期和結束日期都是 None,則不進行任何檢查 64 | if self.start_date is None and self.end_date is None: 65 | return self 66 | 67 | if not all([self.start_date, self.end_date]): 68 | raise ValidationError('開始日期和結束日期必須同時提供或同時不提供') 69 | 70 | # 顯式告訴 Mypy 這兩個變數在這裡是非 None 的 71 | assert self.start_date is not None 72 | assert self.end_date is not None 73 | 74 | try: 75 | start_date_dt = datetime.strptime(self.start_date, '%Y-%m-%d') 76 | end_date_dt = datetime.strptime(self.end_date, '%Y-%m-%d') 77 | except ValueError: 78 | raise ValidationError('日期格式無效,應為 YYYY-MM-DD') 79 | 80 | if start_date_dt > end_date_dt: 81 | raise ValidationError('開始日期必須早於結束日期') 82 | 83 | return self 84 | -------------------------------------------------------------------------------- /post/tests.py: -------------------------------------------------------------------------------- 1 | # Create your tests here. 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | authors = ["Kyo Huang "] 3 | description = "A Django project to demonstrate how to use Django Ninja" 4 | name = "django-ninja-tutorial" 5 | version = "0.1.0" 6 | 7 | [tool.poetry.dependencies] 8 | django = "^4.2" 9 | django-ninja = "^1.3.0" 10 | pillow = "^10.4.0" 11 | python = "^3.12" 12 | 13 | [tool.poetry.group.dev.dependencies] 14 | django-stubs = "^5.0.4" 15 | mypy = "^1.11.2" 16 | pre-commit = "^3.8.0" 17 | pytest = "^8.3.2" 18 | pytest-django = "^4.9.0" 19 | ruff = "0.6.3" 20 | 21 | [build-system] 22 | build-backend = "poetry.core.masonry.api" 23 | requires = ["poetry-core"] 24 | 25 | [project] 26 | requires-python = ">=3.12" # 影響 pyupgrade 檢查與自動修正的版本 27 | 28 | [tool.ruff] # https://docs.astral.sh/ruff/settings/#top-level 29 | exclude = ["**/migrations/", "**/manage.py"] # 排除 migrations 目錄和 manage.py 檔案 30 | line-length = 100 31 | 32 | [tool.ruff.lint] # https://docs.astral.sh/ruff/settings/#lint 33 | ignore = [ 34 | "E402", # module level import not at top of file 35 | ] 36 | select = [ 37 | "E", # pycodestyle errors 38 | "W", # pycodestyle warnings 39 | "F", # pyflakes 40 | "I", # isort 41 | "UP", # pyupgrade 42 | ] 43 | 44 | [tool.ruff.format] # https://docs.astral.sh/ruff/settings/#format 45 | quote-style = "single" # 引號風格,雙引號是預設值 46 | 47 | [tool.mypy] 48 | allow_redefinition = true 49 | check_untyped_defs = true 50 | disable_error_code = ["empty-body"] 51 | disallow_incomplete_defs = true 52 | disallow_untyped_calls = true 53 | disallow_untyped_defs = true 54 | exclude = "^(migrations|.*manage\\.py)$" 55 | force_union_syntax = true 56 | force_uppercase_builtins = true 57 | ignore_missing_imports = true 58 | incremental = true 59 | plugins = ["mypy_django_plugin.main"] 60 | show_traceback = true 61 | strict_optional = true 62 | warn_redundant_casts = true 63 | warn_return_any = true 64 | warn_unreachable = true 65 | warn_unused_configs = true 66 | warn_unused_ignores = true 67 | 68 | [tool.django-stubs] 69 | django_settings_module = "NinjaForum.settings" 70 | 71 | [tool.pytest.ini_options] 72 | addopts = [ 73 | "--ds=NinjaForum.settings", 74 | ] 75 | filterwarnings = [ 76 | "ignore::Warning", 77 | ] 78 | python_files = [ 79 | "tests.py", 80 | "test_*.py", 81 | ] 82 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | annotated-types==0.7.0 ; python_version >= "3.12" and python_version < "4.0" 2 | asgiref==3.8.1 ; python_version >= "3.12" and python_version < "4.0" 3 | cfgv==3.4.0 ; python_version >= "3.12" and python_version < "4.0" 4 | colorama==0.4.6 ; python_version >= "3.12" and python_version < "4.0" and sys_platform == "win32" 5 | distlib==0.3.8 ; python_version >= "3.12" and python_version < "4.0" 6 | django-ninja==1.3.0 ; python_version >= "3.12" and python_version < "4.0" 7 | django-stubs-ext==5.0.4 ; python_version >= "3.12" and python_version < "4.0" 8 | django-stubs==5.0.4 ; python_version >= "3.12" and python_version < "4.0" 9 | django==4.2.16 ; python_version >= "3.12" and python_version < "4.0" 10 | filelock==3.15.4 ; python_version >= "3.12" and python_version < "4.0" 11 | identify==2.6.0 ; python_version >= "3.12" and python_version < "4.0" 12 | iniconfig==2.0.0 ; python_version >= "3.12" and python_version < "4.0" 13 | mypy-extensions==1.0.0 ; python_version >= "3.12" and python_version < "4.0" 14 | mypy==1.11.2 ; python_version >= "3.12" and python_version < "4.0" 15 | nodeenv==1.9.1 ; python_version >= "3.12" and python_version < "4.0" 16 | packaging==24.1 ; python_version >= "3.12" and python_version < "4.0" 17 | pillow==10.4.0 ; python_version >= "3.12" and python_version < "4.0" 18 | platformdirs==4.2.2 ; python_version >= "3.12" and python_version < "4.0" 19 | pluggy==1.5.0 ; python_version >= "3.12" and python_version < "4.0" 20 | pre-commit==3.8.0 ; python_version >= "3.12" and python_version < "4.0" 21 | pydantic-core==2.20.1 ; python_version >= "3.12" and python_version < "4.0" 22 | pydantic==2.8.2 ; python_version >= "3.12" and python_version < "4.0" 23 | pytest-django==4.9.0 ; python_version >= "3.12" and python_version < "4.0" 24 | pytest==8.3.2 ; python_version >= "3.12" and python_version < "4.0" 25 | pyyaml==6.0.2 ; python_version >= "3.12" and python_version < "4.0" 26 | ruff==0.6.3 ; python_version >= "3.12" and python_version < "4.0" 27 | sqlparse==0.5.1 ; python_version >= "3.12" and python_version < "4.0" 28 | types-pyyaml==6.0.12.20240808 ; python_version >= "3.12" and python_version < "4.0" 29 | typing-extensions==4.12.2 ; python_version >= "3.12" and python_version < "4.0" 30 | tzdata==2024.1 ; python_version >= "3.12" and python_version < "4.0" and sys_platform == "win32" 31 | virtualenv==20.26.3 ; python_version >= "3.12" and python_version < "4.0" 32 | -------------------------------------------------------------------------------- /user/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kyomind/Django-Ninja-Tutorial/65572cc8fd5548eccd8a01abef18646a009e1f72/user/__init__.py -------------------------------------------------------------------------------- /user/api.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import authenticate, login 2 | from django.http import HttpRequest 3 | from ninja import File, Router, UploadedFile 4 | from ninja.errors import HttpError 5 | 6 | from user.models import User 7 | from user.schemas import CreateUserRequest, LoginRequest 8 | 9 | router = Router() 10 | 11 | 12 | @router.get(path='/users/', response=list[str], summary='取得所有使用者') 13 | def get_users(request: HttpRequest) -> list[str]: 14 | """ 15 | 取得所有使用者 16 | """ 17 | users = User.objects.all() 18 | return [user.username for user in users] 19 | 20 | 21 | @router.post(path='/users/', response={201: dict}, summary='新增使用者(註冊)', auth=None) 22 | def create_user(request: HttpRequest, payload: CreateUserRequest) -> tuple[int, dict]: 23 | """ 24 | 新增使用者(註冊) 25 | """ 26 | if User.objects.filter(email=payload.email).exists(): 27 | raise HttpError(409, '使用者 email 已存在') 28 | 29 | user = User( 30 | username=payload.username, 31 | email=payload.email, 32 | bio=payload.bio, 33 | ) 34 | user.set_password(raw_password=payload.password) # 使用 set_password 方法加密密碼 35 | user.save() 36 | return 201, {'id': user.id, 'username': user.username} 37 | 38 | 39 | @router.post(path='/users/{int:user_id}/avatar/', summary='上傳 avatar') 40 | def upload_avatar( 41 | request: HttpRequest, user_id: int, avatar_file: UploadedFile = File() 42 | ) -> dict[str, str]: 43 | """ 44 | 上傳 avatar 45 | """ 46 | # 檢查登入的使用者是否為「本人」 47 | if request.auth.id != user_id: 48 | raise HttpError(403, '無權限上傳其他使用者的 avatar') 49 | 50 | # 檢查檔案類型 51 | if not avatar_file.content_type.startswith('image/'): 52 | raise HttpError(400, '檔案必須是圖片格式') 53 | 54 | user = User.objects.get(id=user_id) 55 | user.avatar = avatar_file 56 | user.save() 57 | return {'detail': '圖片上傳成功'} 58 | 59 | 60 | @router.post(path='/users/login/', summary='登入使用者', auth=None) 61 | def login_user(request: HttpRequest, payload: LoginRequest) -> dict[str, str]: 62 | """ 63 | 登入使用者 64 | """ 65 | user = authenticate(request, username=payload.username, password=payload.password) 66 | if user is None: 67 | raise HttpError(401, '帳號或密碼錯誤') 68 | 69 | login(request, user) # 將使用者登入狀態保存至 session 70 | return {'message': '登入成功'} 71 | -------------------------------------------------------------------------------- /user/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class UserConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'user' 7 | -------------------------------------------------------------------------------- /user/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.16 on 2024-09-08 09:00 2 | 3 | import django.contrib.auth.models 4 | import django.contrib.auth.validators 5 | from django.db import migrations, models 6 | import django.utils.timezone 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ('auth', '0012_alter_user_first_name_max_length'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='User', 20 | fields=[ 21 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('password', models.CharField(max_length=128, verbose_name='password')), 23 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 24 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), 25 | ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), 26 | ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), 27 | ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), 28 | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), 29 | ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), 30 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), 31 | ('email', models.EmailField(max_length=254, unique=True)), 32 | ('bio', models.TextField(null=True)), 33 | ('groups', models.ManyToManyField(blank=True, related_name='custom_user_set', to='auth.group')), 34 | ('user_permissions', models.ManyToManyField(blank=True, related_name='custom_user_permissions_set', to='auth.permission')), 35 | ], 36 | options={ 37 | 'verbose_name': 'user', 38 | 'verbose_name_plural': 'users', 39 | 'abstract': False, 40 | }, 41 | managers=[ 42 | ('objects', django.contrib.auth.models.UserManager()), 43 | ], 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /user/migrations/0002_user_avatar.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.16 on 2024-09-30 11:32 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('user', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='user', 15 | name='avatar', 16 | field=models.ImageField(null=True, upload_to='avatars/'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /user/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kyomind/Django-Ninja-Tutorial/65572cc8fd5548eccd8a01abef18646a009e1f72/user/migrations/__init__.py -------------------------------------------------------------------------------- /user/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AbstractUser 2 | from django.db import models 3 | 4 | 5 | # 自定義的 User 模型,繼承自 AbstractUser 6 | class User(AbstractUser): 7 | email = models.EmailField(unique=True) # 強制唯一的 email 8 | bio = models.TextField(null=True) # 個人簡介欄位(可選) 9 | 10 | # XXX 以下設定主要避免發生權限衝突,讀者可以自行參考,不需要完全理解 11 | # 設置不同的 related_name 來避免與 Django 預設模型發生衝突 12 | groups = models.ManyToManyField( 13 | 'auth.Group', 14 | related_name='custom_user_set', # 自定義 related_name 避免衝突 15 | blank=True, 16 | ) 17 | 18 | user_permissions = models.ManyToManyField( 19 | 'auth.Permission', 20 | related_name='custom_user_permissions_set', # 自定義 related_name 避免衝突 21 | blank=True, 22 | ) 23 | avatar = models.ImageField(upload_to='avatars/', null=True) 24 | 25 | def __str__(self) -> str: 26 | return self.username 27 | -------------------------------------------------------------------------------- /user/schemas.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Self 3 | 4 | from django.core.exceptions import ValidationError 5 | from ninja import Field, Schema 6 | from pydantic import field_validator, model_validator 7 | 8 | 9 | class CreateUserRequest(Schema): 10 | username: str = Field(examples=['Alice']) 11 | email: str = Field(examples=['alice@example.com']) 12 | password: str = Field(min_length=8, examples=['password123']) 13 | confirm_password: str = Field(min_length=8, examples=['password123']) 14 | bio: str | None = Field(default=None, examples=['Hello, I am Alice.']) 15 | 16 | @field_validator('password') 17 | @classmethod 18 | def validate_password_contains_number(cls, v: str) -> str: 19 | """ 20 | 驗證密碼至少包含一個數字 21 | """ 22 | if not re.search(r'\d', v): 23 | raise ValidationError('密碼必須包含至少一個數字') 24 | return v 25 | 26 | @model_validator(mode='after') 27 | def check_passwords_match(self) -> Self: 28 | if self.password != self.confirm_password: 29 | raise ValidationError('密碼和確認密碼必須相同') 30 | return self 31 | 32 | 33 | class LoginRequest(Schema): 34 | username: str = Field(examples=['Alice']) 35 | password: str = Field(examples=['password123']) 36 | -------------------------------------------------------------------------------- /user/tests.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.test import Client 3 | 4 | from user.models import User 5 | 6 | pytestmark = pytest.mark.django_db 7 | 8 | 9 | def test_get_users(authenticated_client: Client) -> None: 10 | """ 11 | 測試取得所有使用者 12 | """ 13 | response = authenticated_client.get('/users/') 14 | assert response.status_code == 200 15 | 16 | 17 | def test_create_user(client: Client) -> None: 18 | """ 19 | 測試新增使用者(註冊) 20 | """ 21 | response = client.post( 22 | '/users/', 23 | data={ 24 | 'username': 'testuser2', 25 | 'email': 'testuser2@example.com', 26 | 'password': 'testpassword2123', 27 | 'confirm_password': 'testpassword2123', 28 | }, 29 | content_type='application/json', 30 | ) 31 | assert response.status_code == 201 32 | 33 | 34 | def test_login_user(client: Client, user: User) -> None: 35 | """ 36 | 測試登入使用者 37 | """ 38 | response = client.post( 39 | '/users/login/', 40 | data={'username': 'testuser', 'password': 'testpassword123'}, 41 | content_type='application/json', 42 | ) 43 | assert response.status_code == 200 44 | --------------------------------------------------------------------------------