├── tests ├── __init__.py ├── settings.py └── test_hooks.py ├── drf_hooks ├── migrations │ ├── __init__.py │ ├── 0002_alter_hook_user.py │ └── 0001_initial.py ├── __init__.py ├── signals.py ├── urls.py ├── views.py ├── serializers.py ├── admin.py ├── utils.py ├── client.py └── models.py ├── Makefile ├── MANIFEST ├── AUTHORS.md ├── LICENSE.md ├── .gitignore ├── pyproject.toml ├── .github └── workflows │ └── ci.yml ├── README.md └── poetry.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /drf_hooks/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /drf_hooks/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (0, 1, 3) 2 | -------------------------------------------------------------------------------- /drf_hooks/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | hook_event = Signal() 4 | raw_hook_event = Signal() 5 | -------------------------------------------------------------------------------- /drf_hooks/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework import routers 2 | 3 | from .views import HookViewSet 4 | 5 | router = routers.SimpleRouter() 6 | router.register(r"webhooks", HookViewSet, "webhook") 7 | 8 | urlpatterns = router.urls 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PATH := $(PATH):$(HOME)/.local/bin 2 | SHELL := env PATH=$(PATH) /bin/bash 3 | 4 | .PHONY: build format test lint 5 | 6 | build: 7 | poetry install 8 | 9 | format: 10 | poetry run ruff check . --select I --fix 11 | poetry run ruff format . 12 | 13 | lint: 14 | poetry run ruff check . --diff 15 | poetry run ruff format . --check --diff 16 | 17 | test: 18 | poetry run pytest tests/ 19 | -------------------------------------------------------------------------------- /MANIFEST: -------------------------------------------------------------------------------- 1 | # file GENERATED by distutils, do NOT edit 2 | setup.cfg 3 | setup.py 4 | drf_hooks/__init__.py 5 | drf_hooks/admin.py 6 | drf_hooks/client.py 7 | drf_hooks/models.py 8 | drf_hooks/serializers.py 9 | drf_hooks/signals.py 10 | drf_hooks/tests.py 11 | drf_hooks/urls.py 12 | drf_hooks/utils.py 13 | drf_hooks/views.py 14 | drf_hooks/migrations/0001_initial.py 15 | drf_hooks/migrations/0002_alter_hook_user.py 16 | drf_hooks/migrations/__init__.py 17 | -------------------------------------------------------------------------------- /drf_hooks/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework import viewsets 2 | 3 | from .models import get_hook_model 4 | from .serializers import HookSerializer 5 | 6 | 7 | class HookViewSet(viewsets.ModelViewSet): 8 | """Retrieve, create, update or destroy webhooks.""" 9 | 10 | queryset = get_hook_model().objects.all() 11 | model = get_hook_model() 12 | serializer_class = HookSerializer 13 | # permission_classes = (CustomDjangoModelPermissions,) 14 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | ## Authors 2 | 3 | - Angira Tripathi 4 | - Sander Koelstra 5 | 6 | ### django-rest-hooks 7 | 8 | drf-Hooks is a fork a Django REST Hooks. The original authors were: 9 | 10 | #### Development Lead 11 | 12 | - Bryan Helmig 13 | 14 | #### Patches and Suggestions 15 | 16 | - [Bryan Helmig](https://github.com/bryanhelmig) 17 | - [Arnaud Limbourg](https://github.com/arnaudlimbourg) 18 | - [tdruez](https://github.com/tdruez) 19 | - [Maina Nick](https://github.com/mainanick) 20 | - Jonathan Moss 21 | - [Erik Wickstrom](https://github.com/erikcw) 22 | - [Yaroslav Klyuyev](https://github.com/imposeren) 23 | -------------------------------------------------------------------------------- /drf_hooks/migrations/0002_alter_hook_user.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-03-17 15:00 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ("drf_hooks", "0001_initial"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="hook", 17 | name="user", 18 | field=models.ForeignKey( 19 | on_delete=django.db.models.deletion.CASCADE, 20 | related_name="%(class)ss", 21 | to=settings.AUTH_USER_MODEL, 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /drf_hooks/serializers.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from rest_framework import serializers 3 | 4 | from drf_hooks.models import get_hook_model 5 | 6 | 7 | class HookSerializer(serializers.ModelSerializer): 8 | event = serializers.ChoiceField(choices=list(settings.HOOK_EVENTS)) 9 | user = serializers.HiddenField(default=serializers.CurrentUserDefault()) 10 | headers = serializers.JSONField(write_only=True, required=False) 11 | 12 | def create(self, validated_data): 13 | """Recreating identical hooks fails silently""" 14 | obj, created = get_hook_model().objects.get_or_create(**validated_data) 15 | return obj 16 | 17 | class Meta: 18 | model = get_hook_model() 19 | fields = "__all__" 20 | read_only_fields = ("user",) 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ## ISC License 2 | 3 | Copyright (c) 2021-2025 AM-Flow b.v. 4 | 5 | Copyright (c) 2016 Zapier Inc. 6 | 7 | Permission to use, copy, modify, and/or distribute this software for any 8 | purpose with or without fee is hereby granted, provided that the above 9 | copyright notice and this permission notice appear in all copies. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 12 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 13 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 14 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 15 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 16 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 17 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 18 | -------------------------------------------------------------------------------- /drf_hooks/admin.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.conf import settings 3 | from django.contrib import admin 4 | 5 | from .models import get_hook_model 6 | 7 | 8 | class HookForm(forms.ModelForm): 9 | """ 10 | Model form to handle registered events, assuring 11 | only events declared on HOOK_EVENTS settings 12 | can be registered. 13 | """ 14 | 15 | class Meta: 16 | model = get_hook_model() 17 | fields = ["user", "target", "event", "headers"] 18 | 19 | def __init__(self, *args, **kwargs): 20 | super(HookForm, self).__init__(*args, **kwargs) 21 | self.fields["event"] = forms.ChoiceField(choices=self.get_admin_events()) 22 | 23 | @classmethod 24 | def get_admin_events(cls): 25 | return [(x, x) for x in settings.HOOK_EVENTS] 26 | 27 | 28 | class HookAdmin(admin.ModelAdmin): 29 | list_display = [f.name for f in get_hook_model()._meta.fields] 30 | raw_id_fields = [ 31 | "user", 32 | ] 33 | form = HookForm 34 | 35 | 36 | admin.site.register(get_hook_model(), HookAdmin) 37 | -------------------------------------------------------------------------------- /drf_hooks/utils.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | 3 | from django.apps import apps as django_apps 4 | from django.conf import settings 5 | from django.core.exceptions import ImproperlyConfigured 6 | 7 | 8 | def get_module(path): 9 | """ 10 | A modified duplicate from Django's built in backend 11 | retriever. 12 | 13 | slugify = get_module('django.template.defaultfilters.slugify') 14 | """ 15 | try: 16 | mod_name, func_name = path.rsplit(".", 1) 17 | mod = import_module(mod_name) 18 | except ImportError as e: 19 | raise ImportError('Error importing alert function {0}: "{1}"'.format(mod_name, e)) 20 | try: 21 | func = getattr(mod, func_name) 22 | except AttributeError: 23 | raise ImportError( 24 | ('Module "{0}" does not define a "{1}" function').format(mod_name, func_name) 25 | ) 26 | return func 27 | 28 | 29 | def get_hook_model(): 30 | """ 31 | Returns the Custom Hook model if defined in settings, 32 | otherwise the default Hook model. 33 | """ 34 | model_label = getattr(settings, "HOOK_CUSTOM_MODEL", "drf_hooks.Hook") 35 | try: 36 | return django_apps.get_model(model_label, require_ready=False) 37 | except ValueError: 38 | raise ImproperlyConfigured("HOOK_CUSTOM_MODEL must be of the form 'app_label.model_name'") 39 | except LookupError: 40 | raise ImproperlyConfigured("HOOK_CUSTOM_MODEL refers to unknown model '%s'" % model_label) 41 | -------------------------------------------------------------------------------- /drf_hooks/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1 on 2021-07-27 12:28 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | import drf_hooks.models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="Hook", 20 | fields=[ 21 | ( 22 | "id", 23 | models.AutoField( 24 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 25 | ), 26 | ), 27 | ("created", models.DateTimeField(auto_now_add=True)), 28 | ("updated", models.DateTimeField(auto_now=True)), 29 | ("event", models.CharField(db_index=True, max_length=64, verbose_name="Event")), 30 | ("target", models.URLField(max_length=255, verbose_name="Target URL")), 31 | ("headers", models.JSONField(default=drf_hooks.models.get_default_headers)), 32 | ( 33 | "user", 34 | models.ForeignKey( 35 | on_delete=django.db.models.deletion.CASCADE, 36 | related_name="hooks", 37 | to=settings.AUTH_USER_MODEL, 38 | ), 39 | ), 40 | ], 41 | options={ 42 | "abstract": False, 43 | "swappable": "HOOK_CUSTOM_MODEL", 44 | }, 45 | ), 46 | ] 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/pycharm,python 2 | 3 | ### PyCharm ### 4 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 5 | 6 | *.iml 7 | 8 | ## Directory-based project format: 9 | .idea/ 10 | 11 | ## File-based project format: 12 | *.ipr 13 | *.iws 14 | 15 | ## Plugin-specific files: 16 | 17 | # IntelliJ 18 | /out/ 19 | 20 | # mpeltonen/sbt-idea plugin 21 | .idea_modules/ 22 | 23 | # JIRA plugin 24 | atlassian-ide-plugin.xml 25 | 26 | # Crashlytics plugin (for Android Studio and IntelliJ) 27 | com_crashlytics_export_strings.xml 28 | crashlytics.properties 29 | crashlytics-build.properties 30 | fabric.properties 31 | 32 | 33 | ### Python ### 34 | # Byte-compiled / optimized / DLL files 35 | __pycache__/ 36 | *.py[cod] 37 | *$py.class 38 | 39 | # C extensions 40 | *.so 41 | 42 | # Distribution / packaging 43 | .Python 44 | env/ 45 | build/ 46 | develop-eggs/ 47 | dist/ 48 | downloads/ 49 | eggs/ 50 | .eggs/ 51 | lib/ 52 | lib64/ 53 | parts/ 54 | sdist/ 55 | var/ 56 | *.egg-info/ 57 | .installed.cfg 58 | *.egg 59 | 60 | # PyInstaller 61 | # Usually these files are written by a python script from a template 62 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 63 | *.manifest 64 | *.spec 65 | 66 | # Installer logs 67 | pip-log.txt 68 | pip-delete-this-directory.txt 69 | 70 | # Unit test / coverage reports 71 | htmlcov/ 72 | .tox/ 73 | .coverage 74 | .coverage.* 75 | .cache 76 | nosetests.xml 77 | coverage.xml 78 | *,cover 79 | .hypothesis/ 80 | 81 | # Translations 82 | *.mo 83 | *.pot 84 | 85 | # Django stuff: 86 | *.log 87 | 88 | # Sphinx documentation 89 | docs/_build/ 90 | 91 | # PyBuilder 92 | target/ 93 | 94 | #SQLite 95 | *.sqlite 96 | .sass-cache 97 | 98 | #Test Coverage 99 | tests/reports/ 100 | /pytest_report.xml 101 | -------------------------------------------------------------------------------- /drf_hooks/client.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import threading 3 | 4 | import requests 5 | from django.conf import settings 6 | 7 | __CLIENT = None 8 | 9 | 10 | def get_client(): 11 | global __CLIENT 12 | if __CLIENT is None: 13 | __CLIENT = Client() if getattr(settings, "HOOK_THREADING", True) else requests.Session() 14 | return __CLIENT 15 | 16 | 17 | class FlushThread(threading.Thread): 18 | def __init__(self, client): 19 | threading.Thread.__init__(self) 20 | self.client = client 21 | 22 | def run(self): 23 | self.client.sync_flush() 24 | 25 | 26 | class Client(object): 27 | """ 28 | Manages a simple pool of threads to flush the queue of requests. 29 | """ 30 | 31 | def __init__(self, num_threads=3): 32 | self.queue = collections.deque() 33 | 34 | self.flush_lock = threading.Lock() 35 | self.num_threads = num_threads 36 | self.flush_threads = [FlushThread(self) for _ in range(self.num_threads)] 37 | self.total_sent = 0 38 | 39 | def enqueue(self, method, *args, **kwargs): 40 | self.queue.append((method, args, kwargs)) 41 | self.refresh_threads() 42 | 43 | def get(self, *args, **kwargs): 44 | self.enqueue("get", *args, **kwargs) 45 | 46 | def post(self, *args, **kwargs): 47 | self.enqueue("post", *args, **kwargs) 48 | 49 | def put(self, *args, **kwargs): 50 | self.enqueue("put", *args, **kwargs) 51 | 52 | def delete(self, *args, **kwargs): 53 | self.enqueue("delete", *args, **kwargs) 54 | 55 | def refresh_threads(self): 56 | with self.flush_lock: 57 | # refresh if there are jobs to do and no threads are alive 58 | if len(self.queue) > 0: 59 | to_refresh = [ 60 | index 61 | for index, thread in enumerate(self.flush_threads) 62 | if not thread.is_alive() 63 | ] 64 | for index in to_refresh: 65 | self.flush_threads[index] = FlushThread(self) 66 | self.flush_threads[index].start() 67 | 68 | def sync_flush(self): 69 | session = requests.Session() 70 | while self.queue: 71 | method, args, kwargs = self.queue.pop() 72 | getattr(session, method)(*args, **kwargs) 73 | self.total_sent += 1 74 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "drf-hooks" 3 | version = "0.1.5" 4 | authors = ["Angira Tripathi ", "Sander Koelstra "] 5 | readme = "README.md" 6 | description = "A Django app for webhooks functionality" 7 | license = "ISC" 8 | homepage = "https://github.com/am-flow/drf-hooks" 9 | repository = "https://github.com/am-flow/drf-hooks" 10 | keywords = ["django", "hooks", "webhooks"] 11 | classifiers = [ 12 | "Development Status :: 4 - Beta", 13 | "Environment :: Web Environment", 14 | "Framework :: Django", 15 | "Framework :: Django :: 4.0", 16 | "Framework :: Django :: 4.1", 17 | "Framework :: Django :: 4.2", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: BSD License", 20 | "Operating System :: OS Independent", 21 | "Programming Language :: Python", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Topic :: Internet :: WWW/HTTP", 28 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 29 | ] 30 | packages = [{include = "drf_hooks"}] 31 | exclude = [ 32 | "drf_hooks/tests/*", 33 | ] 34 | 35 | [tool.poetry.dependencies] 36 | python = "^3.9" 37 | Django = ">=3.1,<5" 38 | django-contrib-comments = "2.2.0" 39 | djangorestframework = ">=3.11.1" 40 | pytz = "2025.2" 41 | requests = "^2.32" 42 | 43 | [tool.poetry.group.dev.dependencies] 44 | # general 45 | ipdb = "^0.13.11" 46 | ipython = "^8.11.0" 47 | # tests 48 | pytest = "^7.2.2" 49 | pytest-cov = "^4.0.0" 50 | pytest-mock = "^3.14.0" 51 | pytest-django = "^4.11.1" 52 | # linting 53 | ruff = "^0.1.14" 54 | 55 | [tool.pytest.ini_options] 56 | addopts = "--junitxml=pytest_report.xml --cov=./ --cov-report=term --cov-report=xml" 57 | DJANGO_SETTINGS_MODULE = "tests.settings" 58 | django_find_project = false 59 | cache_dir = ".cache/pytest" 60 | testpaths = ["./tests/"] 61 | 62 | [tool.coverage.run] 63 | omit = ["*/tests/*"] 64 | disable_warnings = ["couldnt-parse"] 65 | 66 | [tool.ruff] 67 | lint.select = ["F", "E", "W", "I"] 68 | line-length = 100 69 | lint.ignore = ["E501"] 70 | cache-dir = ".cache/ruff" 71 | 72 | [tool.ruff.format] 73 | quote-style = "double" 74 | indent-style = "space" 75 | skip-magic-trailing-comma = false 76 | line-ending = "auto" 77 | 78 | [build-system] 79 | requires = ["poetry-core"] 80 | build-backend = "poetry.core.masonry.api" 81 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | HOOK_EVENTS_OVERRIDE = { 4 | "comment.added": "django_comments.Comment.created", 5 | "comment.changed": "django_comments.Comment.updated", 6 | "comment.removed": "django_comments.Comment.deleted", 7 | "comment.moderated": "django_comments.Comment.moderated", 8 | "special.thing": None, 9 | } 10 | 11 | HOOK_SERIALIZERS_OVERRIDE = { 12 | "django_comments.Comment": "tests.test_hooks.CommentSerializer", 13 | } 14 | 15 | ALT_HOOK_EVENTS = dict(HOOK_EVENTS_OVERRIDE) 16 | ALT_HOOK_EVENTS["comment.moderated"] += "+" 17 | ALT_HOOK_SERIALIZERS = {} 18 | 19 | 20 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 21 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 22 | 23 | SECRET_KEY = "test-secret-key-for-testing" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | INSTALLED_APPS = [ 31 | "django.contrib.auth", 32 | "django.contrib.contenttypes", 33 | "django.contrib.sessions", 34 | "django.contrib.messages", 35 | "django.contrib.admin", 36 | "django.contrib.sites", 37 | "django_comments", 38 | "drf_hooks", 39 | ] 40 | 41 | MIDDLEWARE = [ 42 | "django.middleware.security.SecurityMiddleware", 43 | "django.contrib.sessions.middleware.SessionMiddleware", 44 | "django.middleware.common.CommonMiddleware", 45 | "django.middleware.csrf.CsrfViewMiddleware", 46 | "django.contrib.auth.middleware.AuthenticationMiddleware", 47 | ] 48 | 49 | ROOT_URLCONF = "test_urls" 50 | 51 | DATABASES = { 52 | "default": { 53 | "ENGINE": "django.db.backends.sqlite3", 54 | "NAME": ":memory:", 55 | } 56 | } 57 | 58 | TEMPLATES = ( 59 | [ 60 | { 61 | "BACKEND": "django.template.backends.django.DjangoTemplates", 62 | "DIRS": [], 63 | "APP_DIRS": True, 64 | "OPTIONS": { 65 | "context_processors": [ 66 | "django.template.context_processors.request", 67 | "django.contrib.auth.context_processors.auth", 68 | "django.contrib.messages.context_processors.messages", 69 | ], 70 | }, 71 | }, 72 | ], 73 | ) 74 | SITE_ID = 1 75 | HOOK_EVENTS = HOOK_EVENTS_OVERRIDE 76 | HOOK_THREADING = False 77 | HOOK_SERIALIZERS = HOOK_SERIALIZERS_OVERRIDE 78 | # Internationalization 79 | LANGUAGE_CODE = "en-us" 80 | TIME_ZONE = "UTC" 81 | USE_I18N = True 82 | USE_TZ = True 83 | 84 | # Static files (CSS, JavaScript, Images) 85 | STATIC_URL = "/static/" 86 | 87 | # Default primary key field type 88 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 89 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | push: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ['3.9', '3.10', '3.11'] 15 | django-version: ['4.0', '4.1', '4.2'] 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | 25 | - name: Install Poetry 26 | uses: snok/install-poetry@v1 27 | with: 28 | version: latest 29 | virtualenvs-create: true 30 | virtualenvs-in-project: true 31 | installer-parallel: true 32 | 33 | - name: Load cached venv 34 | id: cached-poetry-dependencies 35 | uses: actions/cache@v3 36 | with: 37 | path: .venv 38 | key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} 39 | 40 | - name: Install dependencies 41 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 42 | run: poetry install --no-interaction --no-root 43 | 44 | - name: Install project 45 | run: poetry install --no-interaction 46 | 47 | - name: Install specific Django version 48 | run: poetry add django~=${{ matrix.django-version }} 49 | 50 | - name: Run tests 51 | run: make test 52 | 53 | - name: Upload coverage reports 54 | uses: codecov/codecov-action@v3 55 | if: matrix.python-version == '3.10' && matrix.django-version == '4.2' 56 | with: 57 | file: ./coverage.xml 58 | flags: unittests 59 | name: codecov-umbrella 60 | 61 | lint: 62 | runs-on: ubuntu-latest 63 | 64 | steps: 65 | - uses: actions/checkout@v4 66 | 67 | - name: Set up Python 68 | uses: actions/setup-python@v4 69 | with: 70 | python-version: '3.10' 71 | 72 | - name: Install Poetry 73 | uses: snok/install-poetry@v1 74 | with: 75 | version: latest 76 | virtualenvs-create: true 77 | virtualenvs-in-project: true 78 | 79 | - name: Load cached venv 80 | id: cached-poetry-dependencies 81 | uses: actions/cache@v3 82 | with: 83 | path: .venv 84 | key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} 85 | 86 | - name: Install dependencies 87 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 88 | run: poetry install --no-interaction --no-root 89 | 90 | - name: Install project 91 | run: poetry install --no-interaction 92 | 93 | - name: Run linting 94 | run: make lint 95 | -------------------------------------------------------------------------------- /drf_hooks/models.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections import OrderedDict, defaultdict 3 | 4 | from django.apps import apps 5 | from django.conf import settings 6 | from django.contrib.auth import get_user_model 7 | from django.core import serializers 8 | from django.core.exceptions import ImproperlyConfigured, ValidationError 9 | from django.core.serializers.json import DjangoJSONEncoder 10 | from django.db import models 11 | from django.db.models.signals import post_delete, post_save 12 | from django.dispatch import receiver 13 | from django.utils.module_loading import import_string 14 | 15 | from .client import get_client 16 | from .signals import hook_event, raw_hook_event 17 | 18 | __EVENT_LOOKUP = None 19 | __HOOK_MODEL = None 20 | 21 | if not hasattr(settings, "HOOK_EVENTS"): 22 | raise Exception("You need to define settings.HOOK_EVENTS!") 23 | 24 | 25 | def get_event_lookup(): 26 | global __EVENT_LOOKUP 27 | if not __EVENT_LOOKUP: 28 | __EVENT_LOOKUP = defaultdict(dict) 29 | for event_name, auto in settings.HOOK_EVENTS.items(): 30 | if not auto: 31 | continue 32 | model, action = auto.rstrip("+").rsplit(".", 1) 33 | all_users = auto.endswith("+") 34 | if action in __EVENT_LOOKUP[model]: 35 | raise ImproperlyConfigured( 36 | "settings.HOOK_EVENTS has a duplicate {action} for model " "{model}".format( 37 | action=action, model=model 38 | ) 39 | ) 40 | __EVENT_LOOKUP[model][action] = (event_name, all_users) 41 | return __EVENT_LOOKUP 42 | 43 | 44 | def clear_event_lookup(): 45 | global __EVENT_LOOKUP 46 | __EVENT_LOOKUP = None 47 | 48 | 49 | def get_hook_model(): 50 | """ 51 | Returns the Custom Hook model if defined in settings, 52 | otherwise the default Hook model. 53 | """ 54 | global __HOOK_MODEL 55 | if __HOOK_MODEL is None: 56 | model_label = getattr(settings, "HOOK_CUSTOM_MODEL", "drf_hooks.Hook") 57 | try: 58 | __HOOK_MODEL = apps.get_model(model_label, require_ready=False) 59 | except ValueError: 60 | raise ImproperlyConfigured( 61 | "HOOK_CUSTOM_MODEL must be of the form 'app_label.model_name'" 62 | ) 63 | except LookupError: 64 | raise ImproperlyConfigured( 65 | "HOOK_CUSTOM_MODEL refers to unknown model '%s'" % model_label 66 | ) 67 | return __HOOK_MODEL 68 | 69 | 70 | def get_default_headers(): 71 | return {"Content-Type": "application/json"} 72 | 73 | 74 | class AbstractHook(models.Model): 75 | """ 76 | Stores a representation of a Hook. 77 | """ 78 | 79 | created = models.DateTimeField(auto_now_add=True) 80 | updated = models.DateTimeField(auto_now=True) 81 | 82 | user = models.ForeignKey( 83 | settings.AUTH_USER_MODEL, related_name="%(class)ss", on_delete=models.CASCADE 84 | ) 85 | event = models.CharField("Event", max_length=64, db_index=True) 86 | target = models.URLField("Target URL", max_length=255) 87 | headers = models.JSONField(default=get_default_headers) 88 | 89 | class Meta: 90 | abstract = True 91 | 92 | def clean(self): 93 | """Validation for events.""" 94 | if self.event not in settings.HOOK_EVENTS.keys(): 95 | raise ValidationError("Invalid hook event {evt}.".format(evt=self.event)) 96 | 97 | @staticmethod 98 | def serialize_model(instance): 99 | hook_srls = getattr(settings, "HOOK_SERIALIZERS", {}) 100 | if instance._meta.label in hook_srls: 101 | serializer = import_string(hook_srls[instance._meta.label]) 102 | context = {"request": None} 103 | data = serializer(instance, context=context).data 104 | else: 105 | # if no user defined serializers, fallback to the django builtin! 106 | data = serializers.serialize("python", [instance])[0] 107 | for k, v in data.items(): 108 | if isinstance(v, OrderedDict): 109 | data[k] = dict(v) 110 | if isinstance(data, OrderedDict): 111 | data = dict(data) 112 | return data 113 | 114 | def serialize_hook(self, payload): 115 | serialized_hook = { 116 | "hook": {"id": self.id, "event": self.event, "target": self.target}, 117 | "data": payload, 118 | } 119 | return json.dumps(serialized_hook, cls=DjangoJSONEncoder) 120 | 121 | def deliver_hook(self, serialized_hook): 122 | """Deliver the payload to the target URL.""" 123 | get_client().post(url=self.target, data=serialized_hook, headers=self.headers) 124 | 125 | @classmethod 126 | def find_hooks(cls, event_name, user=None): 127 | hooks = cls.objects.filter(event=event_name) 128 | if not user: 129 | return hooks 130 | return hooks.filter(user=user) 131 | 132 | @classmethod 133 | def find_and_fire_hooks(cls, event_name, payload, user=None): 134 | for hook in cls.find_hooks(event_name, user=user): 135 | serialized_hook = hook.serialize_hook(payload) 136 | hook.deliver_hook(serialized_hook) 137 | 138 | @staticmethod 139 | def get_user(instance, all_users=False): 140 | if all_users: 141 | return 142 | if hasattr(instance, "user"): 143 | return instance.user 144 | elif isinstance(instance, get_user_model()): 145 | return instance 146 | else: 147 | raise ValueError("{} has no `user` property.".format(repr(instance))) 148 | 149 | @classmethod 150 | def handle_model_event(cls, instance, action): 151 | events = get_event_lookup() 152 | model = instance._meta.label 153 | if model not in events or action not in events[model]: 154 | return 155 | event_name, all_users = events[model][action] 156 | payload = cls.serialize_model(instance) 157 | user = cls.get_user(instance, all_users) 158 | cls.find_and_fire_hooks(event_name, payload, user) 159 | 160 | def __unicode__(self): 161 | return "{} => {}".format(self.event, self.target) 162 | 163 | 164 | class Hook(AbstractHook): 165 | class Meta(AbstractHook.Meta): 166 | swappable = "HOOK_CUSTOM_MODEL" 167 | 168 | 169 | ############## 170 | ### EVENTS ### 171 | ############## 172 | 173 | 174 | @receiver(hook_event, dispatch_uid="instance-custom-hook") 175 | def custom_event(sender, instance, action, *args, **kwargs): 176 | """Manually trigger a custom action (or even a standard action).""" 177 | get_hook_model().handle_model_event(instance, action) 178 | 179 | 180 | @receiver(post_save, dispatch_uid="instance-saved-hook") 181 | def model_saved(sender, instance, created, *args, **kwargs): 182 | """Automatically triggers "created" and "updated" actions.""" 183 | action = "created" if created else "updated" 184 | get_hook_model().handle_model_event(instance, action) 185 | 186 | 187 | @receiver(post_delete, dispatch_uid="instance-deleted-hook") 188 | def model_deleted(sender, instance, *args, **kwargs): 189 | """Automatically triggers "deleted" actions.""" 190 | get_hook_model().handle_model_event(instance, "deleted") 191 | 192 | 193 | @receiver(raw_hook_event, dispatch_uid="raw-custom-hook") 194 | def raw_custom_event(sender, event_name, payload, user, **kwargs): 195 | """Give a full payload""" 196 | get_hook_model().find_and_fire_hooks(event_name, payload, user) 197 | -------------------------------------------------------------------------------- /tests/test_hooks.py: -------------------------------------------------------------------------------- 1 | import json 2 | import typing as tp 3 | from unittest.mock import MagicMock 4 | 5 | import pytest 6 | from pytest_mock import MockFixture 7 | 8 | from drf_hooks.client import get_client 9 | from tests.settings import ALT_HOOK_EVENTS 10 | 11 | CLIENT = get_client() 12 | 13 | 14 | from django.contrib.auth.models import User 15 | from django.contrib.sites.models import Site 16 | from django.dispatch import receiver 17 | from django.test.signals import setting_changed 18 | from django_comments.models import Comment 19 | from rest_framework import serializers 20 | 21 | from drf_hooks import models 22 | from drf_hooks.admin import HookForm 23 | 24 | Hook = models.Hook 25 | 26 | urlpatterns = [] 27 | 28 | 29 | class CommentSerializer(serializers.ModelSerializer): 30 | class Meta: 31 | model = Comment 32 | fields = "__all__" 33 | 34 | 35 | @receiver(setting_changed) 36 | def handle_hook_events_change(sender, setting, *args, **kwargs): 37 | if setting == "HOOK_EVENTS": 38 | models.clear_event_lookup() 39 | 40 | 41 | @pytest.fixture 42 | def setup(db) -> tp.Generator[tuple[User, Site], None, None]: 43 | user = User.objects.create_user("bob", "bob@example.com", "password") 44 | site, created = Site.objects.get_or_create(domain="example.com", name="example.com") 45 | yield user, site 46 | 47 | 48 | @pytest.fixture 49 | def mocked_post(mocker: MockFixture) -> MagicMock: 50 | return mocker.patch.object(CLIENT, attribute="post", autospec=True) 51 | 52 | 53 | # @pytest.mark.usefixtures("setup", "mocked_post") 54 | class TestDRFHooks: 55 | """This test Class uses real HTTP calls to a requestbin service, 56 | making it easy to check responses and endpoint history.""" 57 | 58 | def make_hook(self, user, event, target): 59 | return Hook.objects.create(user=user, event=event, target=target) 60 | 61 | ############# 62 | ### TESTS ### 63 | ############# 64 | 65 | def test_get_event_actions_config(self, settings) -> None: 66 | settings.HOOK_EVENTS = ALT_HOOK_EVENTS 67 | assert dict(models.get_event_lookup()) == { 68 | "django_comments.Comment": { 69 | "created": ("comment.added", False), 70 | "updated": ("comment.changed", False), 71 | "deleted": ("comment.removed", False), 72 | "moderated": ("comment.moderated", True), 73 | }, 74 | } 75 | # self.assertEquals( 76 | # models.get_event_lookup(), 77 | # { 78 | # "django_comments.Comment": { 79 | # "created": ("comment.added", False), 80 | # "updated": ("comment.changed", False), 81 | # "deleted": ("comment.removed", False), 82 | # "moderated": ("comment.moderated", True), 83 | # }, 84 | # }, 85 | # ) 86 | 87 | def test_no_hook(self, setup: tuple[User, Site]): 88 | user, site = setup 89 | comment = Comment.objects.create( 90 | site=site, content_object=user, user=user, comment="Hello world!" 91 | ) 92 | 93 | def perform_create_request_cycle(self, site, user, mocked_post): 94 | mocked_post.return_value = None 95 | target = "http://example.com/perform_create_request_cycle" 96 | hook = self.make_hook(user, "comment.added", target) 97 | comment = Comment.objects.create( 98 | site=site, content_object=user, user=user, comment="Hello world!" 99 | ) 100 | return hook, comment, json.loads(mocked_post.call_args_list[0][1]["data"]) 101 | 102 | # @override_settings(HOOK_SERIALIZERS=ALT_HOOK_SERIALIZERS) 103 | def test_simple_comment_hook(self, setup: tuple[User, Site], mocked_post): 104 | """Uses the default serializer.""" 105 | user, site = setup 106 | hook, comment, payload = self.perform_create_request_cycle(site, user, mocked_post) 107 | assert hook.id == payload["hook"]["id"] 108 | assert "comment.added" == payload["hook"]["event"] 109 | assert hook.target == payload["hook"]["target"] 110 | assert str(comment.id) == payload["data"]["object_pk"] 111 | assert "Hello world!" == payload["data"]["comment"] 112 | assert 1 == payload["data"]["user"] 113 | 114 | def test_drf_comment_hook(self, setup: tuple[User, Site], mocked_post): 115 | """Uses the drf serializer.""" 116 | user, site = setup 117 | hook, comment, payload = self.perform_create_request_cycle(site, user, mocked_post) 118 | assert hook.id == payload["hook"]["id"] 119 | assert "comment.added" == payload["hook"]["event"] 120 | assert hook.target == payload["hook"]["target"] 121 | 122 | assert str(comment.id) == payload["data"]["object_pk"] 123 | assert "Hello world!" == payload["data"]["comment"] 124 | assert 1 == payload["data"]["user"] 125 | 126 | def test_full_cycle_comment_hook(self, mocked_post, setup: tuple[User, Site]): 127 | mocked_post.return_value = None 128 | user, site = setup 129 | target = "http://example.com/test_full_cycle_comment_hook" 130 | for event in ("comment.added", "comment.changed", "comment.removed"): 131 | self.make_hook(user, event, target) 132 | 133 | # created 134 | comment = Comment.objects.create( 135 | site=site, content_object=user, user=user, comment="Hello world!" 136 | ) 137 | # updated 138 | comment.comment = "Goodbye world..." 139 | comment.save() 140 | # deleted 141 | comment.delete() 142 | 143 | payloads = [json.loads(call[2]["data"]) for call in mocked_post.mock_calls] 144 | 145 | assert "comment.added" == payloads[0]["hook"]["event"] 146 | assert "comment.changed" == payloads[1]["hook"]["event"] 147 | assert "comment.removed" == payloads[2]["hook"]["event"] 148 | 149 | assert "Hello world!" == payloads[0]["data"]["comment"] 150 | assert "Goodbye world..." == payloads[1]["data"]["comment"] 151 | assert "Goodbye world..." == payloads[2]["data"]["comment"] 152 | 153 | def test_custom_instance_hook(self, mocked_post, setup: tuple[User, Site]): 154 | from drf_hooks.signals import hook_event 155 | 156 | user, site = setup 157 | mocked_post.return_value = None 158 | target = "http://example.com/test_custom_instance_hook" 159 | 160 | self.make_hook(user, "comment.moderated", target) 161 | 162 | comment = Comment.objects.create( 163 | site=site, content_object=user, user=user, comment="Hello world!" 164 | ) 165 | 166 | hook_event.send(sender=comment.__class__, action="moderated", instance=comment) 167 | # time.sleep(1) # should change a setting to turn off async 168 | payloads = [json.loads(call[2]["data"]) for call in mocked_post.mock_calls] 169 | assert "comment.moderated" == payloads[0]["hook"]["event"] 170 | assert "Hello world!" == payloads[0]["data"]["comment"] 171 | 172 | def test_raw_custom_event(self, mocked_post, setup: tuple[User, Site]): 173 | from drf_hooks.signals import raw_hook_event 174 | 175 | user, site = setup 176 | mocked_post.return_value = None 177 | target = "http://example.com/test_raw_custom_event" 178 | 179 | self.make_hook(user, "special.thing", target) 180 | 181 | raw_hook_event.send( 182 | sender=None, event_name="special.thing", payload={"hello": "world!"}, user=user 183 | ) 184 | 185 | payload = json.loads(mocked_post.mock_calls[0][2]["data"]) 186 | 187 | assert "special.thing" == payload["hook"]["event"] 188 | assert "world!" == payload["data"]["hello"] 189 | 190 | def test_valid_form(self, setup: tuple[User, Site]) -> None: 191 | user, site = setup 192 | form_data = { 193 | "user": user.id, 194 | "target": "http://example.com", 195 | "event": HookForm.get_admin_events()[0][0], 196 | "headers": json.dumps({"Content-Type": "application/json"}), 197 | } 198 | form = HookForm(data=form_data) 199 | assert form.is_valid() 200 | 201 | def test_form_save(self, setup: tuple[User, Site]): 202 | user, site = setup 203 | form_data = { 204 | "user": user.id, 205 | "target": "http://example.com", 206 | "event": HookForm.get_admin_events()[0][0], 207 | "headers": json.dumps({"Content-Type": "application/json"}), 208 | } 209 | form = HookForm(data=form_data) 210 | 211 | assert form.is_valid() 212 | instance = form.save() 213 | assert isinstance(instance, Hook) 214 | 215 | def test_invalid_form(self): 216 | form = HookForm(data={}) 217 | assert not form.is_valid() 218 | 219 | # @override_settings(HOOK_CUSTOM_MODEL="drf_hooks.Hook") 220 | def test_get_custom_hook_model(self): 221 | # Using the default Hook model just to exercise get_hook_model's 222 | # lookup machinery. 223 | from drf_hooks.models import AbstractHook, get_hook_model 224 | 225 | HookModel = get_hook_model() 226 | assert HookModel is Hook 227 | assert issubclass(HookModel, AbstractHook) 228 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## What is DRF Hooks? 2 | 3 | drf-hooks is a fork of [Zapier's django-rest-hooks](https://github.com/zapier/django-rest-hooks), which is unfortunately not maintained anymore. 4 | 5 | 6 | ## What are REST Hooks? 7 | 8 | REST Hooks are fancier versions of webhooks. Traditional webhooks are usually 9 | managed manually by the user, but REST Hooks are not! They encourage RESTful 10 | access to the hooks (or subscriptions) themselves. Add one, two or 15 hooks for 11 | any combination of event and URLs, then get notified in real-time by our 12 | bundled threaded callback mechanism. 13 | 14 | The best part is: by reusing Django's signals framework, this library is 15 | dead simple. Here's how to get started: 16 | 17 | 1. Add `'drf_hooks'` to installed apps in settings.py. 18 | 2. Define your `HOOK_EVENTS` and `HOOK_SERIALIZERS` in settings.py. 19 | 3. Start sending hooks! 20 | 21 | Using our **built-in actions**, zero work is required to support *any* basic `created`, 22 | `updated`, and `deleted` actions across any Django model. We also allow for 23 | **custom actions** (IE: beyond **C**R**UD**) to be simply defined and triggered 24 | for any model, as well as truly custom events that let you send arbitrary 25 | payloads. 26 | 27 | By default, this library will just POST Django's JSON serialization of a model, 28 | but you can specify DRF serializers for each model in `HOOK_SERIALIZERS`. 29 | 30 | *Please note:* this package does not implement any UI/API code, it only 31 | provides a handy framework or reference implementation for which to build upon. 32 | If you want to make a Django form or API resource, you'll need to do that yourself 33 | (though we've provided some example bits of code below). 34 | 35 | 36 | ### Requirements 37 | 38 | * Python 3.9+ 39 | * Django 3.1+ 40 | * Django REST framework 3.11+ 41 | 42 | 43 | ### Installing & Configuring 44 | 45 | ``` 46 | pip install drf-hooks 47 | ``` 48 | 49 | or 50 | 51 | ``` 52 | poetry add drf-hooks 53 | ``` 54 | 55 | Next, you'll need to add `drf_hooks` to `INSTALLED_APPS` and configure 56 | your `HOOK_EVENTS` and `HOOK_SERIALIZER` setting: 57 | 58 | ```python 59 | ### settings.py ### 60 | 61 | INSTALLED_APPS = ( 62 | # other apps here... 63 | 'drf_hooks', 64 | ) 65 | 66 | HOOK_EVENTS = { 67 | # 'any.event.name': 'App.Model.Action' (created/updated/deleted) 68 | 'book.added': 'bookstore.Book.created', 69 | 'book.changed': 'bookstore.Book.updated+', 70 | 'book.removed': 'bookstore.Book.deleted', 71 | # and custom events, no extra meta data needed 72 | 'book.read': 'bookstore.Book.read', 73 | 'user.logged_in': None 74 | } 75 | 76 | HOOK_SERIALIZERS = { 77 | # 'App.Model': 'path.to.drf.serializer' 78 | 'bookstore.Book': 'bookstore.serializers.BookSerializer', 79 | } 80 | 81 | 82 | ### bookstore/models.py ### 83 | from django.db import models 84 | from rest_framework import serializers 85 | 86 | 87 | class Book(models.Model): 88 | # NOTE: it is important to have a user property 89 | # as we use it to help find and trigger each Hook 90 | # which is specific to users. If you want a Hook to 91 | # be triggered for all users, add '+' to built-in Hooks 92 | # or pass user=None for custom_hook events 93 | user = models.ForeignKey('auth.User', on_delete=models.CASCADE) 94 | # maybe user is off a related object, so try... 95 | # user = property(lambda self: self.intermediary.user) 96 | 97 | title = models.CharField(max_length=128) 98 | pages = models.PositiveIntegerField() 99 | fiction = models.BooleanField() 100 | 101 | # ... other fields here ... 102 | 103 | def mark_as_read(self): 104 | # models can also have custom defined events 105 | from drf_hooks.signals import hook_event 106 | hook_event.send( 107 | sender=self.__class__, 108 | action='read', 109 | instance=self # the Book object 110 | ) 111 | 112 | ### bookstore/serializers.py ### 113 | 114 | class BookSerializer(serializers.ModelSerializer): 115 | class Meta: 116 | model = Book 117 | fields = '__all__' 118 | ``` 119 | 120 | For the simplest experience, you'll just piggyback off the standard ORM which will 121 | handle the basic `created`, `updated` and `deleted` signals & events: 122 | 123 | ```python 124 | from django.contrib.auth.models import User 125 | from drf_hooks.models import Hook 126 | jrrtolkien = User.objects.create(username='jrrtolkien') 127 | hook = Hook(user=jrrtolkien, 128 | event='book.added', 129 | target='http://example.com/target.php') 130 | hook.save() # creates the hook and stores it for later... 131 | from bookstore.models import Book 132 | book = Book(user=jrrtolkien, 133 | title='The Two Towers', 134 | pages=327, 135 | fiction=True) 136 | book.save() # fires off 'bookstore.Book.created' hook automatically 137 | ... 138 | ``` 139 | 140 | > NOTE: If you try to register an invalid event hook (not listed on HOOK_EVENTS in settings.py) 141 | you will get a **ValidationError**. 142 | 143 | Now that the book has been created, `http://example.com/target.php` will get: 144 | 145 | ``` 146 | POST http://example.com/target.php \ 147 | -H Content-Type: application/json \ 148 | -d '{"hook": { 149 | "id": 123, 150 | "event": "book.added", 151 | "target": "http://example.com/target.php"}, 152 | "data": { 153 | "title": "The Two Towers", 154 | "pages": 327, 155 | "fiction": true}}' 156 | ``` 157 | 158 | You can continue the example, triggering two more hooks in a similar method. However, 159 | since we have no hooks set up for `'book.changed'` or `'book.removed'`, they wouldn't get 160 | triggered anyways. 161 | 162 | ```python 163 | ... 164 | book.title += ': Deluxe Edition' 165 | book.pages = 352 166 | book.save() # would fire off 'bookstore.Book.updated' hook automatically 167 | book.delete() # would fire off 'bookstore.Book.deleted' hook automatically 168 | ``` 169 | 170 | You can also fire custom events with an arbitrary payload: 171 | 172 | ```python 173 | import datetime 174 | from django.contrib.auth.models import User 175 | 176 | from drf_hooks.signals import raw_hook_event 177 | 178 | user = User.objects.get(id=123) 179 | raw_hook_event.send( 180 | sender=None, 181 | event_name='user.logged_in', 182 | payload={ 183 | 'username': user.username, 184 | 'email': user.email, 185 | 'when': datetime.datetime.now().isoformat() 186 | }, 187 | user=user # required: used to filter Hooks 188 | ) 189 | ``` 190 | 191 | 192 | ### How does it work? 193 | 194 | Django has a stellar [signals framework](https://docs.djangoproject.com/en/dev/topics/signals/), all 195 | drf-hooks does is register to receive all `post_save` (created/updated) and `post_delete` (deleted) 196 | signals. It then filters them down by: 197 | 198 | 1. Which `App.Model.Action` actually have an event registered in `settings.HOOK_EVENTS`. 199 | 2. After it verifies that a matching event exists, it searches for matching Hooks via the ORM. 200 | 3. Any Hooks that are found for the User/event combination get sent a payload via POST. 201 | 202 | 203 | ### How would you interact with it in the real world? 204 | 205 | **Let's imagine for a second that you've plugged REST Hooks into your API**. 206 | One could definitely provide a user interface to create hooks themselves via 207 | a standard browser & HTML based CRUD interface, but the real magic is when 208 | the Hook resource is part of an API. 209 | 210 | The basic target functionality is: 211 | 212 | ```shell 213 | POST http://your-app.com/api/hooks?username=me&api_key=abcdef \ 214 | -H Content-Type: application/json \ 215 | -d '{"target": "http://example.com/target.php", 216 | "event": "book.added"}' 217 | ``` 218 | 219 | Now, whenever a Book is created (either via an ORM, a Django form, admin, etc...), 220 | `http://example.com/target.php` will get: 221 | 222 | ```shell 223 | POST http://example.com/target.php \ 224 | -H Content-Type: application/json \ 225 | -d '{"hook": { 226 | "id": 123, 227 | "event": "book.added", 228 | "target": "http://example.com/target.php"}, 229 | "data": { 230 | "title": "Structure and Interpretation of Computer Programs", 231 | "pages": 657, 232 | "fiction": false}}' 233 | ``` 234 | 235 | *It is important to note that drf-hooks will handle all of this hook 236 | callback logic for you automatically.* 237 | 238 | But you can stop it anytime you like with a simple: 239 | 240 | ``` 241 | DELETE http://your-app.com/api/hooks/123?username=me&api_key=abcdef 242 | ``` 243 | 244 | #### Builtin serializers, views, urls 245 | 246 | drf-hooks comes with a `HookSerializer`, `HookViewSet` and an urlconf already baked in. 247 | You can use as many or as little of these as you like. 248 | To use all of the above, add the following to your `urls.py`: 249 | 250 | 251 | ```python 252 | 253 | urlpatterns = [ 254 | # other urls 255 | path('hooks', include('drf_hooks.urls')), 256 | ] 257 | ``` 258 | 259 | ### Extend the Hook model: 260 | 261 | The default `Hook` model fields can be extended using the `AbstractHook` model. 262 | This can also be used to customize other behavior such as hook lookup, serialization, delivery, etc. 263 | 264 | For example, to add a `is_active` field on your hooks and customize hook finding so that only active hooks are fired: 265 | 266 | ```python 267 | ### settings.py ### 268 | 269 | HOOK_CUSTOM_MODEL = 'app_label.ActiveHook' 270 | 271 | ### models.py ### 272 | 273 | from django.db import models 274 | from drf_hooks.models import AbstractHook 275 | 276 | class ActiveHook(AbstractHook): 277 | is_active = models.BooleanField(default=True) 278 | 279 | @classmethod 280 | def find_hooks(cls, event_name, payload, user=None): 281 | hooks = super().find_hooks(event_name, payload, user=user) 282 | return hooks.filter(is_active=True) 283 | ``` 284 | 285 | You can also use this to override hook delivery. 286 | drf-hooks uses a simple Threading pool to deliver your hooks, but you may want to use some kind of background worker. 287 | Here's an example using Celery: 288 | 289 | ```python 290 | ### settings.py ### 291 | 292 | HOOK_CUSTOM_MODEL = 'app_label.CeleryHook' 293 | 294 | ### tasks.py ### 295 | 296 | from celery.task import Task 297 | 298 | import requests 299 | 300 | class DeliverTask(Task): 301 | max_retries = 5 302 | 303 | def run(self, hook_id, payload, **kwargs): 304 | """Deliver the payload to the hook target""" 305 | hook = CeleryHook.objects.get(id=hook_id) 306 | try: 307 | response = requests.post( 308 | url=hook.target, 309 | data=payload, 310 | headers=hook.headers 311 | ) 312 | if response.status_code >= 500: 313 | response.raise_for_status() 314 | except requests.ConnectionError: 315 | delay_in_seconds = 2 ** self.request.retries 316 | self.retry(countdown=delay_in_seconds) 317 | 318 | class CeleryHook(AbstractHook): 319 | def deliver_hook(self, serialized_hook): 320 | DeliverTask.apply_async(hook_id=self.id, payload=serialized_hook) 321 | ``` 322 | 323 | We also don't handle retries or cleanup. Generally, if you get a `410` or 324 | a bunch of `4xx` or `5xx`, you should delete the Hook and let the user know. 325 | 326 | 327 | ### Development 328 | 329 | #### Running tests 330 | 331 | Clone the repo: 332 | 333 | ``` 334 | git clone https://github.com/am-flow/drf-hooks && cd drf-hooks 335 | ``` 336 | 337 | Install dependencies: 338 | 339 | ``` 340 | make build 341 | ``` 342 | 343 | Run tests: 344 | 345 | ``` 346 | make tests 347 | ``` 348 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "asgiref" 5 | version = "3.8.1" 6 | description = "ASGI specs, helper code, and adapters" 7 | optional = false 8 | python-versions = ">=3.8" 9 | files = [ 10 | {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, 11 | {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, 12 | ] 13 | 14 | [package.dependencies] 15 | typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} 16 | 17 | [package.extras] 18 | tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] 19 | 20 | [[package]] 21 | name = "asttokens" 22 | version = "3.0.0" 23 | description = "Annotate AST trees with source code positions" 24 | optional = false 25 | python-versions = ">=3.8" 26 | files = [ 27 | {file = "asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2"}, 28 | {file = "asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7"}, 29 | ] 30 | 31 | [package.extras] 32 | astroid = ["astroid (>=2,<4)"] 33 | test = ["astroid (>=2,<4)", "pytest", "pytest-cov", "pytest-xdist"] 34 | 35 | [[package]] 36 | name = "certifi" 37 | version = "2025.6.15" 38 | description = "Python package for providing Mozilla's CA Bundle." 39 | optional = false 40 | python-versions = ">=3.7" 41 | files = [ 42 | {file = "certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057"}, 43 | {file = "certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b"}, 44 | ] 45 | 46 | [[package]] 47 | name = "charset-normalizer" 48 | version = "3.4.2" 49 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 50 | optional = false 51 | python-versions = ">=3.7" 52 | files = [ 53 | {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, 54 | {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, 55 | {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6"}, 56 | {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d"}, 57 | {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86"}, 58 | {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c"}, 59 | {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0"}, 60 | {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef"}, 61 | {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6"}, 62 | {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366"}, 63 | {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db"}, 64 | {file = "charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a"}, 65 | {file = "charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509"}, 66 | {file = "charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2"}, 67 | {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645"}, 68 | {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd"}, 69 | {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8"}, 70 | {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"}, 71 | {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7"}, 72 | {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9"}, 73 | {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544"}, 74 | {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82"}, 75 | {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0"}, 76 | {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5"}, 77 | {file = "charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a"}, 78 | {file = "charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28"}, 79 | {file = "charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7"}, 80 | {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3"}, 81 | {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a"}, 82 | {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214"}, 83 | {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a"}, 84 | {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd"}, 85 | {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981"}, 86 | {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c"}, 87 | {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b"}, 88 | {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d"}, 89 | {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f"}, 90 | {file = "charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c"}, 91 | {file = "charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e"}, 92 | {file = "charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0"}, 93 | {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf"}, 94 | {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e"}, 95 | {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1"}, 96 | {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c"}, 97 | {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691"}, 98 | {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0"}, 99 | {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b"}, 100 | {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff"}, 101 | {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b"}, 102 | {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148"}, 103 | {file = "charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7"}, 104 | {file = "charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980"}, 105 | {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184"}, 106 | {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa"}, 107 | {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344"}, 108 | {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da"}, 109 | {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02"}, 110 | {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d"}, 111 | {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4"}, 112 | {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f"}, 113 | {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64"}, 114 | {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f"}, 115 | {file = "charset_normalizer-3.4.2-cp37-cp37m-win32.whl", hash = "sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58"}, 116 | {file = "charset_normalizer-3.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2"}, 117 | {file = "charset_normalizer-3.4.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb"}, 118 | {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a"}, 119 | {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45"}, 120 | {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5"}, 121 | {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1"}, 122 | {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027"}, 123 | {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b"}, 124 | {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455"}, 125 | {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01"}, 126 | {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58"}, 127 | {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681"}, 128 | {file = "charset_normalizer-3.4.2-cp38-cp38-win32.whl", hash = "sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7"}, 129 | {file = "charset_normalizer-3.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a"}, 130 | {file = "charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4"}, 131 | {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7"}, 132 | {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836"}, 133 | {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597"}, 134 | {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7"}, 135 | {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f"}, 136 | {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba"}, 137 | {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12"}, 138 | {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518"}, 139 | {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5"}, 140 | {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3"}, 141 | {file = "charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471"}, 142 | {file = "charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e"}, 143 | {file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"}, 144 | {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, 145 | ] 146 | 147 | [[package]] 148 | name = "colorama" 149 | version = "0.4.6" 150 | description = "Cross-platform colored terminal text." 151 | optional = false 152 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 153 | files = [ 154 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 155 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 156 | ] 157 | 158 | [[package]] 159 | name = "coverage" 160 | version = "7.9.1" 161 | description = "Code coverage measurement for Python" 162 | optional = false 163 | python-versions = ">=3.9" 164 | files = [ 165 | {file = "coverage-7.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc94d7c5e8423920787c33d811c0be67b7be83c705f001f7180c7b186dcf10ca"}, 166 | {file = "coverage-7.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:16aa0830d0c08a2c40c264cef801db8bc4fc0e1892782e45bcacbd5889270509"}, 167 | {file = "coverage-7.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf95981b126f23db63e9dbe4cf65bd71f9a6305696fa5e2262693bc4e2183f5b"}, 168 | {file = "coverage-7.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f05031cf21699785cd47cb7485f67df619e7bcdae38e0fde40d23d3d0210d3c3"}, 169 | {file = "coverage-7.9.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb4fbcab8764dc072cb651a4bcda4d11fb5658a1d8d68842a862a6610bd8cfa3"}, 170 | {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0f16649a7330ec307942ed27d06ee7e7a38417144620bb3d6e9a18ded8a2d3e5"}, 171 | {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cea0a27a89e6432705fffc178064503508e3c0184b4f061700e771a09de58187"}, 172 | {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e980b53a959fa53b6f05343afbd1e6f44a23ed6c23c4b4c56c6662bbb40c82ce"}, 173 | {file = "coverage-7.9.1-cp310-cp310-win32.whl", hash = "sha256:70760b4c5560be6ca70d11f8988ee6542b003f982b32f83d5ac0b72476607b70"}, 174 | {file = "coverage-7.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a66e8f628b71f78c0e0342003d53b53101ba4e00ea8dabb799d9dba0abbbcebe"}, 175 | {file = "coverage-7.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:95c765060e65c692da2d2f51a9499c5e9f5cf5453aeaf1420e3fc847cc060582"}, 176 | {file = "coverage-7.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ba383dc6afd5ec5b7a0d0c23d38895db0e15bcba7fb0fa8901f245267ac30d86"}, 177 | {file = "coverage-7.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37ae0383f13cbdcf1e5e7014489b0d71cc0106458878ccde52e8a12ced4298ed"}, 178 | {file = "coverage-7.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69aa417a030bf11ec46149636314c24c8d60fadb12fc0ee8f10fda0d918c879d"}, 179 | {file = "coverage-7.9.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a4be2a28656afe279b34d4f91c3e26eccf2f85500d4a4ff0b1f8b54bf807338"}, 180 | {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:382e7ddd5289f140259b610e5f5c58f713d025cb2f66d0eb17e68d0a94278875"}, 181 | {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e5532482344186c543c37bfad0ee6069e8ae4fc38d073b8bc836fc8f03c9e250"}, 182 | {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a39d18b3f50cc121d0ce3838d32d58bd1d15dab89c910358ebefc3665712256c"}, 183 | {file = "coverage-7.9.1-cp311-cp311-win32.whl", hash = "sha256:dd24bd8d77c98557880def750782df77ab2b6885a18483dc8588792247174b32"}, 184 | {file = "coverage-7.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:6b55ad10a35a21b8015eabddc9ba31eb590f54adc9cd39bcf09ff5349fd52125"}, 185 | {file = "coverage-7.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ad935f0016be24c0e97fc8c40c465f9c4b85cbbe6eac48934c0dc4d2568321e"}, 186 | {file = "coverage-7.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8de12b4b87c20de895f10567639c0797b621b22897b0af3ce4b4e204a743626"}, 187 | {file = "coverage-7.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5add197315a054e92cee1b5f686a2bcba60c4c3e66ee3de77ace6c867bdee7cb"}, 188 | {file = "coverage-7.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600a1d4106fe66f41e5d0136dfbc68fe7200a5cbe85610ddf094f8f22e1b0300"}, 189 | {file = "coverage-7.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a876e4c3e5a2a1715a6608906aa5a2e0475b9c0f68343c2ada98110512ab1d8"}, 190 | {file = "coverage-7.9.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81f34346dd63010453922c8e628a52ea2d2ccd73cb2487f7700ac531b247c8a5"}, 191 | {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:888f8eee13f2377ce86d44f338968eedec3291876b0b8a7289247ba52cb984cd"}, 192 | {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9969ef1e69b8c8e1e70d591f91bbc37fc9a3621e447525d1602801a24ceda898"}, 193 | {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:60c458224331ee3f1a5b472773e4a085cc27a86a0b48205409d364272d67140d"}, 194 | {file = "coverage-7.9.1-cp312-cp312-win32.whl", hash = "sha256:5f646a99a8c2b3ff4c6a6e081f78fad0dde275cd59f8f49dc4eab2e394332e74"}, 195 | {file = "coverage-7.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:30f445f85c353090b83e552dcbbdad3ec84c7967e108c3ae54556ca69955563e"}, 196 | {file = "coverage-7.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:af41da5dca398d3474129c58cb2b106a5d93bbb196be0d307ac82311ca234342"}, 197 | {file = "coverage-7.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:31324f18d5969feef7344a932c32428a2d1a3e50b15a6404e97cba1cc9b2c631"}, 198 | {file = "coverage-7.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c804506d624e8a20fb3108764c52e0eef664e29d21692afa375e0dd98dc384f"}, 199 | {file = "coverage-7.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef64c27bc40189f36fcc50c3fb8f16ccda73b6a0b80d9bd6e6ce4cffcd810bbd"}, 200 | {file = "coverage-7.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4fe2348cc6ec372e25adec0219ee2334a68d2f5222e0cba9c0d613394e12d86"}, 201 | {file = "coverage-7.9.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34ed2186fe52fcc24d4561041979a0dec69adae7bce2ae8d1c49eace13e55c43"}, 202 | {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25308bd3d00d5eedd5ae7d4357161f4df743e3c0240fa773ee1b0f75e6c7c0f1"}, 203 | {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73e9439310f65d55a5a1e0564b48e34f5369bee943d72c88378f2d576f5a5751"}, 204 | {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ab6be0859141b53aa89412a82454b482c81cf750de4f29223d52268a86de67"}, 205 | {file = "coverage-7.9.1-cp313-cp313-win32.whl", hash = "sha256:64bdd969456e2d02a8b08aa047a92d269c7ac1f47e0c977675d550c9a0863643"}, 206 | {file = "coverage-7.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:be9e3f68ca9edb897c2184ad0eee815c635565dbe7a0e7e814dc1f7cbab92c0a"}, 207 | {file = "coverage-7.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:1c503289ffef1d5105d91bbb4d62cbe4b14bec4d13ca225f9c73cde9bb46207d"}, 208 | {file = "coverage-7.9.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0b3496922cb5f4215bf5caaef4cf12364a26b0be82e9ed6d050f3352cf2d7ef0"}, 209 | {file = "coverage-7.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9565c3ab1c93310569ec0d86b017f128f027cab0b622b7af288696d7ed43a16d"}, 210 | {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2241ad5dbf79ae1d9c08fe52b36d03ca122fb9ac6bca0f34439e99f8327ac89f"}, 211 | {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bb5838701ca68b10ebc0937dbd0eb81974bac54447c55cd58dea5bca8451029"}, 212 | {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b30a25f814591a8c0c5372c11ac8967f669b97444c47fd794926e175c4047ece"}, 213 | {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2d04b16a6062516df97969f1ae7efd0de9c31eb6ebdceaa0d213b21c0ca1a683"}, 214 | {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7931b9e249edefb07cd6ae10c702788546341d5fe44db5b6108a25da4dca513f"}, 215 | {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52e92b01041151bf607ee858e5a56c62d4b70f4dac85b8c8cb7fb8a351ab2c10"}, 216 | {file = "coverage-7.9.1-cp313-cp313t-win32.whl", hash = "sha256:684e2110ed84fd1ca5f40e89aa44adf1729dc85444004111aa01866507adf363"}, 217 | {file = "coverage-7.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:437c576979e4db840539674e68c84b3cda82bc824dd138d56bead1435f1cb5d7"}, 218 | {file = "coverage-7.9.1-cp313-cp313t-win_arm64.whl", hash = "sha256:18a0912944d70aaf5f399e350445738a1a20b50fbea788f640751c2ed9208b6c"}, 219 | {file = "coverage-7.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6f424507f57878e424d9a95dc4ead3fbdd72fd201e404e861e465f28ea469951"}, 220 | {file = "coverage-7.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:535fde4001b2783ac80865d90e7cc7798b6b126f4cd8a8c54acfe76804e54e58"}, 221 | {file = "coverage-7.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02532fd3290bb8fa6bec876520842428e2a6ed6c27014eca81b031c2d30e3f71"}, 222 | {file = "coverage-7.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:56f5eb308b17bca3bbff810f55ee26d51926d9f89ba92707ee41d3c061257e55"}, 223 | {file = "coverage-7.9.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfa447506c1a52271f1b0de3f42ea0fa14676052549095e378d5bff1c505ff7b"}, 224 | {file = "coverage-7.9.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9ca8e220006966b4a7b68e8984a6aee645a0384b0769e829ba60281fe61ec4f7"}, 225 | {file = "coverage-7.9.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:49f1d0788ba5b7ba65933f3a18864117c6506619f5ca80326b478f72acf3f385"}, 226 | {file = "coverage-7.9.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:68cd53aec6f45b8e4724c0950ce86eacb775c6be01ce6e3669fe4f3a21e768ed"}, 227 | {file = "coverage-7.9.1-cp39-cp39-win32.whl", hash = "sha256:95335095b6c7b1cc14c3f3f17d5452ce677e8490d101698562b2ffcacc304c8d"}, 228 | {file = "coverage-7.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:e1b5191d1648acc439b24721caab2fd0c86679d8549ed2c84d5a7ec1bedcc244"}, 229 | {file = "coverage-7.9.1-pp39.pp310.pp311-none-any.whl", hash = "sha256:db0f04118d1db74db6c9e1cb1898532c7dcc220f1d2718f058601f7c3f499514"}, 230 | {file = "coverage-7.9.1-py3-none-any.whl", hash = "sha256:66b974b145aa189516b6bf2d8423e888b742517d37872f6ee4c5be0073bd9a3c"}, 231 | {file = "coverage-7.9.1.tar.gz", hash = "sha256:6cf43c78c4282708a28e466316935ec7489a9c487518a77fa68f716c67909cec"}, 232 | ] 233 | 234 | [package.dependencies] 235 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 236 | 237 | [package.extras] 238 | toml = ["tomli"] 239 | 240 | [[package]] 241 | name = "decorator" 242 | version = "5.2.1" 243 | description = "Decorators for Humans" 244 | optional = false 245 | python-versions = ">=3.8" 246 | files = [ 247 | {file = "decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a"}, 248 | {file = "decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360"}, 249 | ] 250 | 251 | [[package]] 252 | name = "django" 253 | version = "4.2.23" 254 | description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." 255 | optional = false 256 | python-versions = ">=3.8" 257 | files = [ 258 | {file = "django-4.2.23-py3-none-any.whl", hash = "sha256:dafbfaf52c2f289bd65f4ab935791cb4fb9a198f2a5ba9faf35d7338a77e9803"}, 259 | {file = "django-4.2.23.tar.gz", hash = "sha256:42fdeaba6e6449d88d4f66de47871015097dc6f1b87910db00a91946295cfae4"}, 260 | ] 261 | 262 | [package.dependencies] 263 | asgiref = ">=3.6.0,<4" 264 | sqlparse = ">=0.3.1" 265 | tzdata = {version = "*", markers = "sys_platform == \"win32\""} 266 | 267 | [package.extras] 268 | argon2 = ["argon2-cffi (>=19.1.0)"] 269 | bcrypt = ["bcrypt"] 270 | 271 | [[package]] 272 | name = "django-contrib-comments" 273 | version = "2.2.0" 274 | description = "The code formerly known as django.contrib.comments." 275 | optional = false 276 | python-versions = "*" 277 | files = [ 278 | {file = "django-contrib-comments-2.2.0.tar.gz", hash = "sha256:48de00f15677e016a216aeff205d6e00e4391c9a5702136c64119c472b7356da"}, 279 | {file = "django_contrib_comments-2.2.0-py3-none-any.whl", hash = "sha256:2ca79060bbc8fc5b636981ef6e50f35ab83649af75fc1be47bf770636be3271c"}, 280 | ] 281 | 282 | [package.dependencies] 283 | Django = ">=2.2" 284 | 285 | [[package]] 286 | name = "djangorestframework" 287 | version = "3.16.0" 288 | description = "Web APIs for Django, made easy." 289 | optional = false 290 | python-versions = ">=3.9" 291 | files = [ 292 | {file = "djangorestframework-3.16.0-py3-none-any.whl", hash = "sha256:bea7e9f6b96a8584c5224bfb2e4348dfb3f8b5e34edbecb98da258e892089361"}, 293 | {file = "djangorestframework-3.16.0.tar.gz", hash = "sha256:f022ff46613584de994c0c6a4aebbace5fd700555fbe9d33b865ebf173eba6c9"}, 294 | ] 295 | 296 | [package.dependencies] 297 | django = ">=4.2" 298 | 299 | [[package]] 300 | name = "exceptiongroup" 301 | version = "1.3.0" 302 | description = "Backport of PEP 654 (exception groups)" 303 | optional = false 304 | python-versions = ">=3.7" 305 | files = [ 306 | {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, 307 | {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, 308 | ] 309 | 310 | [package.dependencies] 311 | typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} 312 | 313 | [package.extras] 314 | test = ["pytest (>=6)"] 315 | 316 | [[package]] 317 | name = "executing" 318 | version = "2.2.0" 319 | description = "Get the currently executing AST node of a frame, and other information" 320 | optional = false 321 | python-versions = ">=3.8" 322 | files = [ 323 | {file = "executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa"}, 324 | {file = "executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755"}, 325 | ] 326 | 327 | [package.extras] 328 | tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] 329 | 330 | [[package]] 331 | name = "idna" 332 | version = "3.10" 333 | description = "Internationalized Domain Names in Applications (IDNA)" 334 | optional = false 335 | python-versions = ">=3.6" 336 | files = [ 337 | {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, 338 | {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, 339 | ] 340 | 341 | [package.extras] 342 | all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] 343 | 344 | [[package]] 345 | name = "iniconfig" 346 | version = "2.1.0" 347 | description = "brain-dead simple config-ini parsing" 348 | optional = false 349 | python-versions = ">=3.8" 350 | files = [ 351 | {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, 352 | {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, 353 | ] 354 | 355 | [[package]] 356 | name = "ipdb" 357 | version = "0.13.13" 358 | description = "IPython-enabled pdb" 359 | optional = false 360 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 361 | files = [ 362 | {file = "ipdb-0.13.13-py3-none-any.whl", hash = "sha256:45529994741c4ab6d2388bfa5d7b725c2cf7fe9deffabdb8a6113aa5ed449ed4"}, 363 | {file = "ipdb-0.13.13.tar.gz", hash = "sha256:e3ac6018ef05126d442af680aad863006ec19d02290561ac88b8b1c0b0cfc726"}, 364 | ] 365 | 366 | [package.dependencies] 367 | decorator = {version = "*", markers = "python_version > \"3.6\""} 368 | ipython = {version = ">=7.31.1", markers = "python_version > \"3.6\""} 369 | tomli = {version = "*", markers = "python_version > \"3.6\" and python_version < \"3.11\""} 370 | 371 | [[package]] 372 | name = "ipython" 373 | version = "8.18.1" 374 | description = "IPython: Productive Interactive Computing" 375 | optional = false 376 | python-versions = ">=3.9" 377 | files = [ 378 | {file = "ipython-8.18.1-py3-none-any.whl", hash = "sha256:e8267419d72d81955ec1177f8a29aaa90ac80ad647499201119e2f05e99aa397"}, 379 | {file = "ipython-8.18.1.tar.gz", hash = "sha256:ca6f079bb33457c66e233e4580ebfc4128855b4cf6370dddd73842a9563e8a27"}, 380 | ] 381 | 382 | [package.dependencies] 383 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 384 | decorator = "*" 385 | exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} 386 | jedi = ">=0.16" 387 | matplotlib-inline = "*" 388 | pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} 389 | prompt-toolkit = ">=3.0.41,<3.1.0" 390 | pygments = ">=2.4.0" 391 | stack-data = "*" 392 | traitlets = ">=5" 393 | typing-extensions = {version = "*", markers = "python_version < \"3.10\""} 394 | 395 | [package.extras] 396 | all = ["black", "curio", "docrepr", "exceptiongroup", "ipykernel", "ipyparallel", "ipywidgets", "matplotlib", "matplotlib (!=3.2.0)", "nbconvert", "nbformat", "notebook", "numpy (>=1.22)", "pandas", "pickleshare", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio (<0.22)", "qtconsole", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "trio", "typing-extensions"] 397 | black = ["black"] 398 | doc = ["docrepr", "exceptiongroup", "ipykernel", "matplotlib", "pickleshare", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio (<0.22)", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "typing-extensions"] 399 | kernel = ["ipykernel"] 400 | nbconvert = ["nbconvert"] 401 | nbformat = ["nbformat"] 402 | notebook = ["ipywidgets", "notebook"] 403 | parallel = ["ipyparallel"] 404 | qtconsole = ["qtconsole"] 405 | test = ["pickleshare", "pytest (<7.1)", "pytest-asyncio (<0.22)", "testpath"] 406 | test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.22)", "pandas", "pickleshare", "pytest (<7.1)", "pytest-asyncio (<0.22)", "testpath", "trio"] 407 | 408 | [[package]] 409 | name = "jedi" 410 | version = "0.19.2" 411 | description = "An autocompletion tool for Python that can be used for text editors." 412 | optional = false 413 | python-versions = ">=3.6" 414 | files = [ 415 | {file = "jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9"}, 416 | {file = "jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0"}, 417 | ] 418 | 419 | [package.dependencies] 420 | parso = ">=0.8.4,<0.9.0" 421 | 422 | [package.extras] 423 | docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] 424 | qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] 425 | testing = ["Django", "attrs", "colorama", "docopt", "pytest (<9.0.0)"] 426 | 427 | [[package]] 428 | name = "matplotlib-inline" 429 | version = "0.1.7" 430 | description = "Inline Matplotlib backend for Jupyter" 431 | optional = false 432 | python-versions = ">=3.8" 433 | files = [ 434 | {file = "matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca"}, 435 | {file = "matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90"}, 436 | ] 437 | 438 | [package.dependencies] 439 | traitlets = "*" 440 | 441 | [[package]] 442 | name = "packaging" 443 | version = "25.0" 444 | description = "Core utilities for Python packages" 445 | optional = false 446 | python-versions = ">=3.8" 447 | files = [ 448 | {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, 449 | {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, 450 | ] 451 | 452 | [[package]] 453 | name = "parso" 454 | version = "0.8.4" 455 | description = "A Python Parser" 456 | optional = false 457 | python-versions = ">=3.6" 458 | files = [ 459 | {file = "parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18"}, 460 | {file = "parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d"}, 461 | ] 462 | 463 | [package.extras] 464 | qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] 465 | testing = ["docopt", "pytest"] 466 | 467 | [[package]] 468 | name = "pexpect" 469 | version = "4.9.0" 470 | description = "Pexpect allows easy control of interactive console applications." 471 | optional = false 472 | python-versions = "*" 473 | files = [ 474 | {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, 475 | {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, 476 | ] 477 | 478 | [package.dependencies] 479 | ptyprocess = ">=0.5" 480 | 481 | [[package]] 482 | name = "pluggy" 483 | version = "1.6.0" 484 | description = "plugin and hook calling mechanisms for python" 485 | optional = false 486 | python-versions = ">=3.9" 487 | files = [ 488 | {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, 489 | {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, 490 | ] 491 | 492 | [package.extras] 493 | dev = ["pre-commit", "tox"] 494 | testing = ["coverage", "pytest", "pytest-benchmark"] 495 | 496 | [[package]] 497 | name = "prompt-toolkit" 498 | version = "3.0.51" 499 | description = "Library for building powerful interactive command lines in Python" 500 | optional = false 501 | python-versions = ">=3.8" 502 | files = [ 503 | {file = "prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07"}, 504 | {file = "prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed"}, 505 | ] 506 | 507 | [package.dependencies] 508 | wcwidth = "*" 509 | 510 | [[package]] 511 | name = "ptyprocess" 512 | version = "0.7.0" 513 | description = "Run a subprocess in a pseudo terminal" 514 | optional = false 515 | python-versions = "*" 516 | files = [ 517 | {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, 518 | {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, 519 | ] 520 | 521 | [[package]] 522 | name = "pure-eval" 523 | version = "0.2.3" 524 | description = "Safely evaluate AST nodes without side effects" 525 | optional = false 526 | python-versions = "*" 527 | files = [ 528 | {file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"}, 529 | {file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"}, 530 | ] 531 | 532 | [package.extras] 533 | tests = ["pytest"] 534 | 535 | [[package]] 536 | name = "pygments" 537 | version = "2.19.2" 538 | description = "Pygments is a syntax highlighting package written in Python." 539 | optional = false 540 | python-versions = ">=3.8" 541 | files = [ 542 | {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, 543 | {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, 544 | ] 545 | 546 | [package.extras] 547 | windows-terminal = ["colorama (>=0.4.6)"] 548 | 549 | [[package]] 550 | name = "pytest" 551 | version = "7.4.4" 552 | description = "pytest: simple powerful testing with Python" 553 | optional = false 554 | python-versions = ">=3.7" 555 | files = [ 556 | {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, 557 | {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, 558 | ] 559 | 560 | [package.dependencies] 561 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 562 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 563 | iniconfig = "*" 564 | packaging = "*" 565 | pluggy = ">=0.12,<2.0" 566 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 567 | 568 | [package.extras] 569 | testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 570 | 571 | [[package]] 572 | name = "pytest-cov" 573 | version = "4.1.0" 574 | description = "Pytest plugin for measuring coverage." 575 | optional = false 576 | python-versions = ">=3.7" 577 | files = [ 578 | {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, 579 | {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, 580 | ] 581 | 582 | [package.dependencies] 583 | coverage = {version = ">=5.2.1", extras = ["toml"]} 584 | pytest = ">=4.6" 585 | 586 | [package.extras] 587 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] 588 | 589 | [[package]] 590 | name = "pytest-django" 591 | version = "4.11.1" 592 | description = "A Django plugin for pytest." 593 | optional = false 594 | python-versions = ">=3.8" 595 | files = [ 596 | {file = "pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10"}, 597 | {file = "pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991"}, 598 | ] 599 | 600 | [package.dependencies] 601 | pytest = ">=7.0.0" 602 | 603 | [package.extras] 604 | docs = ["sphinx", "sphinx_rtd_theme"] 605 | testing = ["Django", "django-configurations (>=2.0)"] 606 | 607 | [[package]] 608 | name = "pytest-mock" 609 | version = "3.14.1" 610 | description = "Thin-wrapper around the mock package for easier use with pytest" 611 | optional = false 612 | python-versions = ">=3.8" 613 | files = [ 614 | {file = "pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0"}, 615 | {file = "pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e"}, 616 | ] 617 | 618 | [package.dependencies] 619 | pytest = ">=6.2.5" 620 | 621 | [package.extras] 622 | dev = ["pre-commit", "pytest-asyncio", "tox"] 623 | 624 | [[package]] 625 | name = "pytz" 626 | version = "2025.2" 627 | description = "World timezone definitions, modern and historical" 628 | optional = false 629 | python-versions = "*" 630 | files = [ 631 | {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"}, 632 | {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"}, 633 | ] 634 | 635 | [[package]] 636 | name = "requests" 637 | version = "2.32.4" 638 | description = "Python HTTP for Humans." 639 | optional = false 640 | python-versions = ">=3.8" 641 | files = [ 642 | {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, 643 | {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, 644 | ] 645 | 646 | [package.dependencies] 647 | certifi = ">=2017.4.17" 648 | charset_normalizer = ">=2,<4" 649 | idna = ">=2.5,<4" 650 | urllib3 = ">=1.21.1,<3" 651 | 652 | [package.extras] 653 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 654 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 655 | 656 | [[package]] 657 | name = "ruff" 658 | version = "0.1.15" 659 | description = "An extremely fast Python linter and code formatter, written in Rust." 660 | optional = false 661 | python-versions = ">=3.7" 662 | files = [ 663 | {file = "ruff-0.1.15-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5fe8d54df166ecc24106db7dd6a68d44852d14eb0729ea4672bb4d96c320b7df"}, 664 | {file = "ruff-0.1.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f0bfbb53c4b4de117ac4d6ddfd33aa5fc31beeaa21d23c45c6dd249faf9126f"}, 665 | {file = "ruff-0.1.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0d432aec35bfc0d800d4f70eba26e23a352386be3a6cf157083d18f6f5881c8"}, 666 | {file = "ruff-0.1.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9405fa9ac0e97f35aaddf185a1be194a589424b8713e3b97b762336ec79ff807"}, 667 | {file = "ruff-0.1.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c66ec24fe36841636e814b8f90f572a8c0cb0e54d8b5c2d0e300d28a0d7bffec"}, 668 | {file = "ruff-0.1.15-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:6f8ad828f01e8dd32cc58bc28375150171d198491fc901f6f98d2a39ba8e3ff5"}, 669 | {file = "ruff-0.1.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86811954eec63e9ea162af0ffa9f8d09088bab51b7438e8b6488b9401863c25e"}, 670 | {file = "ruff-0.1.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd4025ac5e87d9b80e1f300207eb2fd099ff8200fa2320d7dc066a3f4622dc6b"}, 671 | {file = "ruff-0.1.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b17b93c02cdb6aeb696effecea1095ac93f3884a49a554a9afa76bb125c114c1"}, 672 | {file = "ruff-0.1.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ddb87643be40f034e97e97f5bc2ef7ce39de20e34608f3f829db727a93fb82c5"}, 673 | {file = "ruff-0.1.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:abf4822129ed3a5ce54383d5f0e964e7fef74a41e48eb1dfad404151efc130a2"}, 674 | {file = "ruff-0.1.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6c629cf64bacfd136c07c78ac10a54578ec9d1bd2a9d395efbee0935868bf852"}, 675 | {file = "ruff-0.1.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1bab866aafb53da39c2cadfb8e1c4550ac5340bb40300083eb8967ba25481447"}, 676 | {file = "ruff-0.1.15-py3-none-win32.whl", hash = "sha256:2417e1cb6e2068389b07e6fa74c306b2810fe3ee3476d5b8a96616633f40d14f"}, 677 | {file = "ruff-0.1.15-py3-none-win_amd64.whl", hash = "sha256:3837ac73d869efc4182d9036b1405ef4c73d9b1f88da2413875e34e0d6919587"}, 678 | {file = "ruff-0.1.15-py3-none-win_arm64.whl", hash = "sha256:9a933dfb1c14ec7a33cceb1e49ec4a16b51ce3c20fd42663198746efc0427360"}, 679 | {file = "ruff-0.1.15.tar.gz", hash = "sha256:f6dfa8c1b21c913c326919056c390966648b680966febcb796cc9d1aaab8564e"}, 680 | ] 681 | 682 | [[package]] 683 | name = "sqlparse" 684 | version = "0.5.3" 685 | description = "A non-validating SQL parser." 686 | optional = false 687 | python-versions = ">=3.8" 688 | files = [ 689 | {file = "sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca"}, 690 | {file = "sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272"}, 691 | ] 692 | 693 | [package.extras] 694 | dev = ["build", "hatch"] 695 | doc = ["sphinx"] 696 | 697 | [[package]] 698 | name = "stack-data" 699 | version = "0.6.3" 700 | description = "Extract data from python stack frames and tracebacks for informative displays" 701 | optional = false 702 | python-versions = "*" 703 | files = [ 704 | {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, 705 | {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, 706 | ] 707 | 708 | [package.dependencies] 709 | asttokens = ">=2.1.0" 710 | executing = ">=1.2.0" 711 | pure-eval = "*" 712 | 713 | [package.extras] 714 | tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] 715 | 716 | [[package]] 717 | name = "tomli" 718 | version = "2.2.1" 719 | description = "A lil' TOML parser" 720 | optional = false 721 | python-versions = ">=3.8" 722 | files = [ 723 | {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, 724 | {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, 725 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, 726 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, 727 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, 728 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, 729 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, 730 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, 731 | {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, 732 | {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, 733 | {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, 734 | {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, 735 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, 736 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, 737 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, 738 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, 739 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, 740 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, 741 | {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, 742 | {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, 743 | {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, 744 | {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, 745 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, 746 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, 747 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, 748 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, 749 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, 750 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, 751 | {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, 752 | {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, 753 | {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, 754 | {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, 755 | ] 756 | 757 | [[package]] 758 | name = "traitlets" 759 | version = "5.14.3" 760 | description = "Traitlets Python configuration system" 761 | optional = false 762 | python-versions = ">=3.8" 763 | files = [ 764 | {file = "traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f"}, 765 | {file = "traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7"}, 766 | ] 767 | 768 | [package.extras] 769 | docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] 770 | test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"] 771 | 772 | [[package]] 773 | name = "typing-extensions" 774 | version = "4.14.0" 775 | description = "Backported and Experimental Type Hints for Python 3.9+" 776 | optional = false 777 | python-versions = ">=3.9" 778 | files = [ 779 | {file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"}, 780 | {file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"}, 781 | ] 782 | 783 | [[package]] 784 | name = "tzdata" 785 | version = "2025.2" 786 | description = "Provider of IANA time zone data" 787 | optional = false 788 | python-versions = ">=2" 789 | files = [ 790 | {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, 791 | {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, 792 | ] 793 | 794 | [[package]] 795 | name = "urllib3" 796 | version = "2.5.0" 797 | description = "HTTP library with thread-safe connection pooling, file post, and more." 798 | optional = false 799 | python-versions = ">=3.9" 800 | files = [ 801 | {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, 802 | {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, 803 | ] 804 | 805 | [package.extras] 806 | brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] 807 | h2 = ["h2 (>=4,<5)"] 808 | socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] 809 | zstd = ["zstandard (>=0.18.0)"] 810 | 811 | [[package]] 812 | name = "wcwidth" 813 | version = "0.2.13" 814 | description = "Measures the displayed width of unicode strings in a terminal" 815 | optional = false 816 | python-versions = "*" 817 | files = [ 818 | {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, 819 | {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, 820 | ] 821 | 822 | [metadata] 823 | lock-version = "2.0" 824 | python-versions = "^3.9" 825 | content-hash = "feacf42991e4129b70a5da85f19369140c74f8462044a299d5a96a03ac285a28" 826 | --------------------------------------------------------------------------------