13 Commits
Author SHA1 Message Date
ponas 7efa230deb feat: build an image and deploy it to lynx from ci
CI / tests (push) Successful in 1m52s
CI / prek (push) Successful in 17s
CI / image (push) Successful in 1m41s
CI / deploy-lynx (push) Successful in 27s
2026-11-03 04:13:05 +01:00
ponas 8f58d7fcf9 docs: add AGENTS.md and rewrite readme 2026-11-03 03:52:52 +01:00
ponas 7cd4b5dfc6 chore: remove fabric deploy script 2026-11-03 03:52:52 +01:00
ponas a21b4c9417 ci: replace gitlab ci with gitea workflow running pytest and prek 2026-11-03 03:52:46 +01:00
ponas 5474ad7bdc chore: add prek hooks with ruff 2026-11-03 03:52:46 +01:00
ponas 9350064c63 feat: serve static files with whitenoise 2026-11-03 03:52:46 +01:00
ponas 712f370245 refactor!: replace django rest framework with django-ninja 2026-11-03 03:52:46 +01:00
ponas cad499c19a test: cover api behaviour with pytest at 100% 2026-11-03 03:52:41 +01:00
ponas 08ffd39674 fix: accept filename argument in import_valiases 2026-11-03 03:52:41 +01:00
ponas 5ef366f667 fix: only delete exact alias matches in delete_bulk 2026-11-03 03:52:41 +01:00
ponas 83eb8f07f2 fix: hash user passwords on update 2026-11-03 03:52:41 +01:00
ponas cd8de0847c chore: upgrade to django 5.2 lts 2026-11-03 03:52:20 +01:00
ponas dd634d3717 build: manage python 3.14, uv and prek with mise 2026-11-03 03:52:19 +01:00
48 changed files with 1794 additions and 1499 deletions

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
.git
.venv
venv
**/__pycache__
.pytest_cache
.ruff_cache
.coverage
db.sqlite3
static
dpdapisite/local_settings.py
+80
View File
@@ -0,0 +1,80 @@
name: CI
# Tests and prek on every push and pull request. Pushes to main deploy to lynx while DEPLOY_LYNX is true.
on:
push:
pull_request:
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install mise
run: |
curl -fsSL https://mise.run | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install pinned tools
run: mise install
# The prod group needs MySQL headers; tests run on SQLite.
- name: Install Python dependencies
run: mise exec -- uv sync --locked --no-group prod
- name: Unit tests
run: mise exec -- uv run --no-sync pytest
# The hooks in prek.toml.
prek:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: j178/prek-action@v1
# kaniko: the runner has no docker socket
image:
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.DEPLOY_LYNX == 'true'
runs-on: ubuntu-latest
needs:
- tests
- prek
container: ghcr.io/osscontainertools/kaniko:v1.28.6-debug
defaults:
run:
shell: sh
steps:
- name: Build and push
env:
GIT_USERNAME: ${{ github.actor }}
GIT_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
auth=$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_TOKEN" | base64 | tr -d '\n')
printf '{"auths":{"git.neuf.no":{"auth":"%s"}}}' "$auth" > /kaniko/.docker/config.json
/kaniko/executor \
--context "git://git.neuf.no/${{ github.repository }}.git#${{ github.ref }}#${{ github.sha }}" \
--destination "git.neuf.no/edb/django-postfix-dovecot-api:${{ github.sha }}" \
--build-arg "SENTRY_RELEASE=${{ github.sha }}" \
--cache=true --cache-repo git.neuf.no/edb/django-postfix-dovecot-api-cache \
--snapshot-mode=redo --use-new-run \
--reproducible
# The key's forced command deploys the sha it is given
deploy-lynx:
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.DEPLOY_LYNX == 'true'
runs-on: ubuntu-latest
needs:
- image
steps:
- name: Deploy
run: |
command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )
eval $(ssh-agent -s)
echo "${{ secrets.SSH_PRIVATE_KEY }}" | tr -d '\r' | ssh-add -
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
ssh mxapi-deploy@lynx.neuf.no "${{ github.sha }}"
+5 -2
View File
@@ -1,7 +1,10 @@
*.swp *.swp
*.pyc *.pyc
.idea .idea
venv
dpdapi/local.py
local_settings.py local_settings.py
db.sqlite3 db.sqlite3
venv
.venv
.coverage
.pytest_cache
/static
-42
View File
@@ -1,42 +0,0 @@
default:
image: python:3.8-slim
cache:
paths:
- .cache/pip
- .venv
test:
before_script:
- poetry install
script:
- bin/ensure-python-deps.sh
- poetry run python manage.py migrate
- poetry run python manage.py check
- poetry run coverage run --source=dpdapi --omit=dpdapi/migrations/* -m pytest -v
- poetry run coverage report
- poetry run coverage xml
- poetry run bin/lint
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
deploy:
stage: deploy
before_script:
# Install ssh if not already
- 'command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )'
- eval $(ssh-agent -s)
# Make SSH key available
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
# Add known host keys
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
- chmod 644 ~/.ssh/known_hosts
script:
- ssh gitdeploy@lynx.neuf.no "$(< bin/deploy)"
only:
- main
-36
View File
@@ -1,36 +0,0 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: local
hooks:
- id: black
name: black
entry: black
args: [--check]
language: system
require_serial: true
types: [python]
- id: isort
name: isort
entry: isort
args: [--check]
language: system
types: [python]
- id: pycln
name: pycln
entry: pycln
args: [--check]
language: system
types: [python]
- id: pyupgrade
name: pyupgrade
entry: pyupgrade
args: [--py38-plus]
language: system
types: [python]
- id: django-upgrade
name: django-upgrade
entry: django-upgrade
language: system
types: [python]
args: [--target-version, "3.2"]
+31
View File
@@ -0,0 +1,31 @@
# AGENTS.md
REST API (Django + django-ninja) for managing the virtual domains, users and aliases that Postfix and Dovecot read
from the database. Deployed as mxapi.neuf.no.
## Setup and commands
Tools are managed by [mise](https://mise.jdx.dev) (Python, uv, prek) and Python deps by uv.
```sh
mise install # Python, uv, prek
uv sync # deps + dev tools
prek install # git hooks
uv run pytest # tests, must keep 100% coverage
prek run --all-files # ruff check, ruff format and file hygiene hooks
```
## Rules
- Use [Conventional Commits](https://www.conventionalcommits.org/) for commit messages, e.g.
`feat: add alias search`, `fix(api): reject unknown domains`, `chore(deps): bump django`.
- Postfix and Dovecot query the `dpdapi_domain`, `dpdapi_alias` and `dpdapi_user` tables directly. Never rename
tables or columns, or change how values are stored (addresses are lowercased, passwords are SHA-512 crypt).
`tests/test_schema.py` guards this.
- Django stays on 5.2 LTS (and Python 3.14) until lynx has MySQL 8.4, which Django 6 requires.
- [mailadmin](https://git.neuf.no/edb/mailadmin) is the main API client. Don't change URLs, status codes or response fields it relies on;
`tests/test_mailadmin_contract.py` replays its calls.
- Test the API through HTTP in `tests/`, using pytest and the fixtures in `tests/conftest.py`.
- Keep coverage at 100%; `pytest` fails below it.
- Production-only deps (`gunicorn`, `mysqlclient`) live in the `prod` dependency group. Production settings come from
the environment (`dpdapisite/settings_prod.py`).
+31
View File
@@ -0,0 +1,31 @@
FROM python:3.14-slim-trixie AS build
COPY --from=ghcr.io/astral-sh/uv:0.13.6 /uv /bin/uv
# mysqlclient ships no wheels
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential pkg-config default-libmysqlclient-dev \
&& rm -rf /var/lib/apt/lists/*
# Outside /app, so a code change leaves the venv layer alone
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never UV_PROJECT_ENVIRONMENT=/venv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-dev --group prod --no-install-project
COPY . .
RUN DJANGO_SETTINGS_MODULE=dpdapisite.settings_prod SECRET_KEY=collectstatic DB_PASSWORD=collectstatic \
/venv/bin/python manage.py collectstatic --noinput
FROM python:3.14-slim-trixie
RUN apt-get update \
&& apt-get install -y --no-install-recommends libmariadb3 \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 934 --home-dir /app --no-create-home mxapi
COPY --from=build /venv /venv
COPY --from=build /app /app
WORKDIR /app
ARG SENTRY_RELEASE=N/A
ENV PATH=/venv/bin:$PATH \
DJANGO_SETTINGS_MODULE=dpdapisite.settings_prod \
PYTHONUNBUFFERED=1 \
SENTRY_RELEASE=$SENTRY_RELEASE
USER mxapi
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--no-control-socket", "--access-logfile", "-", "dpdapisite.wsgi"]
+16 -13
View File
@@ -1,20 +1,23 @@
# django-postfix-dovecot-api # django-postfix-dovecot-api
## Getting started REST API for managing the virtual domains, users and aliases that Postfix and Dovecot read from the database.
Used by [mailadmin](https://git.neuf.no/edb/mailadmin).
```bash ## Development
sudo apt install libmysqlclient-dev
poetry install ```sh
poetry run bin/run mise install
uv sync
prek install
uv run python manage.py migrate
uv run python manage.py runserver
uv run pytest
``` ```
## Deploy API docs are served at `/api/docs`. Override settings (database, secret key, Sentry) in
`dpdapisite/local_settings.py`.
First you need to have your public key in `/home/gitdeploy/.ssh/authorized_keys` on the server and then you can do: ## Deployment
fab deploy CI builds an image for each push to `main` and deploys it to lynx while the repository variable `DEPLOY_LYNX` is
`true`. The image reads its settings from the environment (`dpdapisite/settings_prod.py`).
## Inspiration:
-
- http://modoboa.readthedocs.org/
- https://github.com/lgunsch/django-vmail
-18
View File
@@ -1,18 +0,0 @@
#!/bin/bash
set -eu
export DJANGO_SETTINGS_MODULE=dusken.settings.prod
PROJECT_PATH=/var/www/neuf.no/django-postfix-dovecot-api
PROCESS_NAME="mxapi.neuf.no"
echo "Deploying mxapi to ${PROJECT_PATH}"
cd "$PROJECT_PATH" || exit 1
git pull
poetry install
umask 022; poetry run python manage.py collectstatic --noinput -c # Collect static
poetry run python manage.py migrate # Run DB migrations
# Reload backend
echo "Reloading supervisor process ${PROCESS_NAME}"
sudo /usr/bin/supervisorctl pid "$PROCESS_NAME" | xargs kill -HUP
-6
View File
@@ -1,6 +0,0 @@
#!/bin/bash
set -eu
poetry config virtualenvs.in-project true
poetry run pip install -U pip wheel
poetry install
-9
View File
@@ -1,9 +0,0 @@
#!/bin/bash
SRC_DIR="dpdapi"
black $SRC_DIR
isort $SRC_DIR
pycln $SRC_DIR
pyupgrade --py38-plus $(find $SRC_DIR -name '*.py')
django-upgrade --target-version 3.2 $(find $SRC_DIR -name '*.py')
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env bash
set -eu
pre-commit run -a
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
python manage.py runserver
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
pytest -v
+3 -3
View File
@@ -5,9 +5,9 @@ from dpdapi.models import Alias, Domain, User
@admin.register(Alias) @admin.register(Alias)
class AliasAdmin(admin.ModelAdmin): class AliasAdmin(admin.ModelAdmin):
list_display = ["source", "destination", "active", "domain"] list_display = ("source", "destination", "active", "domain")
list_filter = ["domain"] list_filter = ("domain",)
search_fields = ["source", "destination"] search_fields = ("source", "destination")
@admin.register(Domain) @admin.register(Domain)
+272
View File
@@ -0,0 +1,272 @@
import logging
import operator
from datetime import datetime
from functools import reduce
from typing import Annotated
from django.contrib.auth import authenticate
from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.validators import validate_email
from django.db import models, transaction
from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.utils import timezone
from ninja import NinjaAPI, PatchDict, Query, Schema, Status
from ninja.errors import ValidationError
from ninja.security import HttpBasicAuth, django_auth
from passlib.hash import sha512_crypt
from pydantic import AfterValidator, PlainSerializer, StringConstraints
from dpdapi.models import Alias, Domain, User
logger = logging.getLogger(__name__)
class BasicAuth(HttpBasicAuth):
def authenticate(self, request, username, password):
return authenticate(request, username=username, password=password)
api = NinjaAPI(title="Postfix/Dovecot API", auth=[BasicAuth(), django_auth], urls_namespace="api")
@api.exception_handler(ValidationError)
def validation_error(request, exc):
return api.create_response(request, {"detail": exc.errors}, status=400)
def _email(value: str) -> str:
try:
validate_email(value)
except DjangoValidationError as e:
raise ValueError("Enter a valid email address.") from e
return value.lower()
Email = Annotated[str, StringConstraints(strip_whitespace=True, max_length=254), AfterValidator(_email)]
Name = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=254)]
Password = Annotated[str, StringConstraints(min_length=1, max_length=512)]
# Rendered in the site's time zone, as Django REST framework did.
LocalDateTime = Annotated[datetime, PlainSerializer(lambda v: timezone.localtime(v).isoformat())]
def invalid(field: str, msg: str) -> ValidationError:
return ValidationError([{"loc": ["body", "payload", field], "msg": msg, "type": "value_error"}])
def check_domains(domain_ids: list[int]):
"""Reject domain ids that don't exist."""
if missing := set(domain_ids) - set(Domain.objects.filter(pk__in=domain_ids).values_list("pk", flat=True)):
raise invalid("domain", f"Invalid pk {sorted(missing)} - object does not exist.")
def check_unique(instance: models.Model, field: str, value: str):
qs = type(instance).objects.filter(**{field: value}).exclude(pk=instance.pk)
if qs.exists():
raise invalid(field, f"{instance._meta.verbose_name} with this {field} already exists.")
def not_null(data: dict) -> dict:
"""PatchDict makes every field optional *and* nullable; only the former is wanted."""
for field, value in data.items():
if value is None:
raise invalid(field, "This field may not be null.")
return data
class Timestamped(Schema):
id: int
created: LocalDateTime
updated: LocalDateTime
# Aliases
class AliasKey(Schema):
source: Email
destination: Email
domain: int
class AliasIn(AliasKey):
active: bool = True
class AliasOut(Timestamped):
source: str
destination: str
domain: int
active: bool
@staticmethod
def resolve_domain(obj):
return obj.domain_id
class AliasFilters(Schema):
domain: int | None = None
domain__name: str | None = None
source__regex: str | None = None
source__iregex: str | None = None
destination__regex: str | None = None
destination__iregex: str | None = None
class Result(Schema):
result: str
@api.get("/aliases/", response=list[AliasOut], tags=["aliases"])
def list_aliases(request, filters: Query[AliasFilters]):
return Alias.objects.filter(**filters.model_dump(exclude_none=True))
@api.post("/aliases/create_bulk/", response={201: list[AliasOut]}, tags=["aliases"])
@transaction.atomic
def create_aliases(request, payload: list[AliasIn]):
"""Create aliases, returning existing ones instead of failing on duplicates."""
check_domains([a.domain for a in payload])
aliases = [
Alias.objects.get_or_create(
source=a.source, destination=a.destination, domain_id=a.domain, defaults={"active": a.active}
)[0]
for a in payload
]
return Status(201, aliases)
@api.delete("/aliases/delete_bulk/", response={200: Result, 204: None}, tags=["aliases"])
@transaction.atomic
def delete_aliases(request, payload: list[AliasKey]):
"""Delete aliases, reporting the ones that didn't exist."""
check_domains([a.domain for a in payload])
keys = {(a.source, a.destination, a.domain) for a in payload}
query = reduce(operator.or_, (Q(source=s, destination=d, domain_id=dom) for s, d, dom in keys), Q(pk__in=[]))
aliases = Alias.objects.filter(query)
deleted = set(aliases.values_list("source", "destination", "domain"))
aliases.delete()
logger.debug("Deleted: %s", ",".join(f"{s}={d}" for s, d, _ in sorted(deleted)))
if missing := keys - deleted:
formatted = ", ".join(f"{s}={d}" for s, d, _ in sorted(missing))
result = f"Aliases '{formatted}' could not be found and were not deleted."
logger.info(result)
return Status(200, {"result": result})
return Status(204, None)
# Domains
class DomainIn(Schema):
name: Name
class DomainOut(Timestamped):
name: str
class DomainFilters(Schema):
name: str | None = None
def save_domain(domain: Domain, data: dict) -> Domain:
if "name" in data:
domain.name = data["name"].lower()
check_unique(domain, "name", domain.name)
domain.save()
return domain
@api.get("/domains/", response=list[DomainOut], tags=["domains"])
def list_domains(request, filters: Query[DomainFilters]):
return Domain.objects.filter(**filters.model_dump(exclude_none=True))
@api.post("/domains/", response={201: DomainOut}, tags=["domains"])
def create_domain(request, payload: DomainIn):
return Status(201, save_domain(Domain(), payload.model_dump()))
@api.get("/domains/{int:pk}/", response=DomainOut, tags=["domains"])
def get_domain(request, pk: int):
return get_object_or_404(Domain, pk=pk)
@api.put("/domains/{int:pk}/", response=DomainOut, tags=["domains"])
def update_domain(request, pk: int, payload: DomainIn):
return save_domain(get_object_or_404(Domain, pk=pk), payload.model_dump())
@api.patch("/domains/{int:pk}/", response=DomainOut, tags=["domains"])
def partial_update_domain(request, pk: int, payload: PatchDict[DomainIn]):
return save_domain(get_object_or_404(Domain, pk=pk), not_null(payload))
@api.delete("/domains/{int:pk}/", response={204: None}, tags=["domains"])
def delete_domain(request, pk: int):
get_object_or_404(Domain, pk=pk).delete()
return Status(204, None)
# Users
class UserIn(Schema):
email: Email
password: Password
domain: int
class UserOut(Timestamped):
email: str
domain: int
@staticmethod
def resolve_domain(obj):
return obj.domain_id
def save_user(user: User, data: dict) -> User:
if "email" in data:
user.email = data["email"]
check_unique(user, "email", user.email)
if "domain" in data:
check_domains([data["domain"]])
user.domain_id = data["domain"]
if "password" in data:
user.password = sha512_crypt.hash(data["password"])
user.save()
return user
@api.get("/users/", response=list[UserOut], tags=["users"])
def list_users(request):
return User.objects.all()
@api.post("/users/", response={201: UserOut}, tags=["users"])
def create_user(request, payload: UserIn):
return Status(201, save_user(User(), payload.model_dump()))
@api.get("/users/{int:pk}/", response=UserOut, tags=["users"])
def get_user(request, pk: int):
return get_object_or_404(User, pk=pk)
@api.put("/users/{int:pk}/", response=UserOut, tags=["users"])
def update_user(request, pk: int, payload: UserIn):
return save_user(get_object_or_404(User, pk=pk), payload.model_dump())
@api.patch("/users/{int:pk}/", response=UserOut, tags=["users"])
def partial_update_user(request, pk: int, payload: PatchDict[UserIn]):
return save_user(get_object_or_404(User, pk=pk), not_null(payload))
@api.delete("/users/{int:pk}/", response={204: None}, tags=["users"])
def delete_user(request, pk: int):
get_object_or_404(User, pk=pk).delete()
return Status(204, None)
-15
View File
@@ -1,15 +0,0 @@
import django_filters
from rest_framework.filters import BaseFilterBackend
from dpdapi.models import Alias
class AliasRegexFilter(django_filters.FilterSet):
class Meta:
model = Alias
fields = {"source": ["iregex", "regex"], "destination": ["iregex", "regex"]}
class AliasRegexFilterBackend(BaseFilterBackend):
def filter_queryset(self, request, queryset, view):
return AliasRegexFilter(request.query_params, queryset=queryset).qs
+28 -30
View File
@@ -1,7 +1,7 @@
import os from pathlib import Path
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db.utils import IntegrityError from django.db import IntegrityError, transaction
from dpdapi.models import Alias, Domain from dpdapi.models import Alias, Domain
@@ -9,54 +9,52 @@ from dpdapi.models import Alias, Domain
class Command(BaseCommand): class Command(BaseCommand):
help = "Import aliases for a single domain at a time, which is the domain of the first source address." help = "Import aliases for a single domain at a time, which is the domain of the first source address."
def handle(self, *args, **options): def add_arguments(self, parser):
if len(args) == 1: parser.add_argument("filename", nargs="?")
if os.path.exists(args[0]):
self.import_file(args[0]) def handle(self, *args, filename=None, **options):
else: if filename is None:
self.stdout.write(f"{args[0]} does not exist")
else:
self.stdout.write("Missing argument") self.stdout.write("Missing argument")
elif not Path(filename).exists():
self.stdout.write(f"{filename} does not exist")
else:
self.import_file(filename)
def import_file(self, filename): def import_file(self, filename):
new_aliases = [] new_aliases = []
counts = {"imported": 0, "existing": 0} counts = {"imported": 0, "existing": 0}
# Open file with Path(filename).open() as f:
with open(filename) as f: for line in f:
# Parse if line.startswith("*: "):
for l in f:
if l.startswith("*: "):
continue # Skip catch all continue # Skip catch all
source, destinations = l.strip().split(": ") source, destinations = line.strip().split(": ")
for destination in destinations.split(","): for destination in destinations.split(","):
if destination.startswith('"|') or "-admin@" in destination: if destination.startswith('"|') or "-admin@" in destination:
continue # Skip mailman-aliases continue # Skip mailman-aliases
new_aliases.append(Alias(source=source, destination=destination)) new_aliases.append(Alias(source=source, destination=destination))
if len(new_aliases) == 0: if not new_aliases:
self.stdout.write("No aliases found") self.stdout.write("No aliases found")
return return
# Lookup domain # Assumes that every alias is in the same domain
domain_name = new_aliases[0].source.split("@")[1] # Assumes that every alias is in the same domain domain_name = new_aliases[0].source.split("@")[1]
d, created = Domain.objects.get_or_create(name=domain_name) domain, created = Domain.objects.get_or_create(name=domain_name)
if created: if created:
self.stdout.write(f"Created domain {d.name}.") self.stdout.write(f"Created domain {domain.name}.")
# Try to create, log if exists. Slow but works # Try to create, log if exists. Slow but works
for a in new_aliases: for alias in new_aliases:
a.domain = d alias.domain = domain
try: try:
a.save() with transaction.atomic():
counts["imported"] += 1 # Woop! alias.save()
counts["imported"] += 1
except IntegrityError: except IntegrityError:
counts["existing"] += 1 # Exists counts["existing"] += 1
self.stdout.write(f"Could not import '{a.source}={a.destination}', already exists.") self.stdout.write(f"Could not import '{alias.source}={alias.destination}', already exists.")
# Print num imported, existing, total
self.stdout.write( self.stdout.write(
"Imported {}, skipped {} existing, total {}".format( f"Imported {counts['imported']}, skipped {counts['existing']} existing, total {len(new_aliases)}"
counts["imported"], counts["existing"], len(new_aliases)
)
) )
+31 -27
View File
@@ -1,58 +1,62 @@
from django.db import migrations, models # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [] dependencies = [
]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name="Alias", name='Alias',
fields=[ fields=[
("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)), ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
("created", models.DateTimeField(auto_now_add=True)), ('created', models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)), ('updated', models.DateTimeField(auto_now=True)),
("source", models.EmailField(max_length=254)), ('source', models.EmailField(max_length=254)),
("destination", models.EmailField(max_length=254)), ('destination', models.EmailField(max_length=254)),
("active", models.BooleanField(default=True)), ('active', models.BooleanField(default=True)),
], ],
options={ options={
"verbose_name_plural": "aliases", 'verbose_name_plural': 'aliases',
}, },
bases=(models.Model,), bases=(models.Model,),
), ),
migrations.CreateModel( migrations.CreateModel(
name="Domain", name='Domain',
fields=[ fields=[
("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)), ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
("created", models.DateTimeField(auto_now_add=True)), ('created', models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)), ('updated', models.DateTimeField(auto_now=True)),
("name", models.CharField(unique=True, max_length=254)), ('name', models.CharField(unique=True, max_length=254)),
], ],
options={ options={
"abstract": False, 'abstract': False,
}, },
bases=(models.Model,), bases=(models.Model,),
), ),
migrations.CreateModel( migrations.CreateModel(
name="User", name='User',
fields=[ fields=[
("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)), ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
("created", models.DateTimeField(auto_now_add=True)), ('created', models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)), ('updated', models.DateTimeField(auto_now=True)),
("email", models.EmailField(unique=True, max_length=254)), ('email', models.EmailField(unique=True, max_length=254)),
("password", models.CharField(max_length=109)), ('password', models.CharField(max_length=109)),
("domain", models.ForeignKey(to="dpdapi.Domain", on_delete=models.CASCADE)), ('domain', models.ForeignKey(to='dpdapi.Domain', on_delete=models.CASCADE)),
], ],
options={ options={
"abstract": False, 'abstract': False,
}, },
bases=(models.Model,), bases=(models.Model,),
), ),
migrations.AddField( migrations.AddField(
model_name="alias", model_name='alias',
name="domain", name='domain',
field=models.ForeignKey(to="dpdapi.Domain", on_delete=models.CASCADE), field=models.ForeignKey(to='dpdapi.Domain', on_delete=models.CASCADE),
preserve_default=True, preserve_default=True,
), ),
] ]
+7 -4
View File
@@ -1,15 +1,18 @@
from django.db import migrations, models # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("dpdapi", "0001_initial"), ('dpdapi', '0001_initial'),
] ]
operations = [ operations = [
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
name="alias", name='alias',
unique_together={("source", "destination", "domain")}, unique_together=set([('source', 'destination', 'domain')]),
), ),
] ]
+3 -3
View File
@@ -6,13 +6,13 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("dpdapi", "0002_auto_20150118_1638"), ('dpdapi', '0002_auto_20150118_1638'),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name="user", model_name='user',
name="password", name='password',
field=models.CharField(max_length=512), field=models.CharField(max_length=512),
), ),
] ]
+4 -4
View File
@@ -15,6 +15,10 @@ class Alias(BaseModel):
domain = models.ForeignKey("dpdapi.Domain", models.CASCADE) domain = models.ForeignKey("dpdapi.Domain", models.CASCADE)
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
class Meta:
verbose_name_plural = "aliases"
unique_together = ("source", "destination", "domain")
def __str__(self): def __str__(self):
return f"{self.source}={self.destination}" return f"{self.source}={self.destination}"
@@ -24,10 +28,6 @@ class Alias(BaseModel):
super().save(*args, **kwargs) super().save(*args, **kwargs)
class Meta:
verbose_name_plural = "aliases"
unique_together = ("source", "destination", "domain")
class Domain(BaseModel): class Domain(BaseModel):
name = models.CharField(max_length=254, unique=True) name = models.CharField(max_length=254, unique=True)
-49
View File
@@ -1,49 +0,0 @@
from passlib.hash import sha512_crypt
from rest_framework import serializers
from .models import Alias, Domain, User
class AliasSerializer(serializers.ModelSerializer):
def create(self, validated_data):
# Workaround to accept duplicates
# FIXME, will fail with existing alias with validated_data['active']=False
alias, created = Alias.objects.get_or_create(**validated_data)
return alias
class Meta:
model = Alias
validators = [] # Disable unique togheter check
fields = "__all__"
class AliasDeleteSerializer(serializers.ModelSerializer):
class Meta:
model = Alias
validators = [] # No Unique togheter check on deletion
fields = "__all__"
class DomainSerializer(serializers.ModelSerializer):
class Meta:
model = Domain
fields = "__all__"
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = "__all__"
extra_kwargs = {"password": {"write_only": True}}
def _hash_password(self, raw_password):
return sha512_crypt.encrypt(raw_password)
def create(self, validated_data):
user = User(
email=validated_data["email"],
domain=validated_data["domain"],
password=self._hash_password(validated_data["password"]),
)
user.save()
return user
-41
View File
@@ -1,41 +0,0 @@
from django.contrib.auth.models import User
from django.urls import reverse
from rest_framework.test import APITestCase
from dpdapi.models import Alias, Domain
class RegexTestCase(APITestCase):
aliases = ["ops-alert@example.com", "yolo-announce@example.com"]
def setUp(self):
user = User.objects.create(username="yolo")
self.domain = Domain.objects.create(name="example.com")
for a in self.aliases:
Alias.objects.create(source=a, destination="ninja@example.com", domain=self.domain)
self.client.force_login(user)
def test_get_all(self):
res = self.client.get(reverse("alias-list"))
self.assertEqual(len(res.data), len(self.aliases))
def test_domain_filter(self):
params = {"domain__name": "example.com"}
res = self.client.get(reverse("alias-list"), data=params)
self.assertEqual(len(res.data), len(self.aliases))
def test_domain_filter_no_hits(self):
params = {"domain__name": "zexample.com"}
res = self.client.get(reverse("alias-list"), data=params)
self.assertEqual(len(res.data), 0)
def test_regex_filter(self):
params = {"source__regex": "^ops-|^ops@"}
res = self.client.get(reverse("alias-list"), data=params)
self.assertEqual(len(res.data), 1)
def test_delete_bulk_single(self):
data = {"destination": "ninja@example.com", "source": "ding@example.com", "domain": self.domain.pk}
Alias.objects.create(source=data["source"], destination=data["destination"], domain=self.domain)
res = self.client.delete(reverse("alias-delete-bulk"), data=[data], format="json")
self.assertEqual(res.status_code, 204, res.data)
-19
View File
@@ -1,19 +0,0 @@
from django.contrib import admin
from django.urls import include, path, re_path
from django.views.generic import RedirectView
from rest_framework import urls as rest_framework_urls
from rest_framework.routers import DefaultRouter
from .views import AliasViewSet, DomainViewSet, UserViewSet
api_router = DefaultRouter()
api_router.register(r"aliases", AliasViewSet)
api_router.register(r"domains", DomainViewSet)
api_router.register(r"users", UserViewSet)
urlpatterns = [
path("", RedirectView.as_view(pattern_name="api-root")),
re_path(r"^api/", include(api_router.urls)),
re_path(r"^admin/", admin.site.urls),
re_path(r"^api-auth/", include(rest_framework_urls, namespace="rest_framework")),
]
-82
View File
@@ -1,82 +0,0 @@
import logging
import operator
from functools import reduce
from django.db.models import Q
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.mixins import ListModelMixin
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from dpdapi.filters import AliasRegexFilterBackend
from dpdapi.models import Alias, Domain, User
from dpdapi.serializers import AliasDeleteSerializer, AliasSerializer, DomainSerializer, UserSerializer
logger = logging.getLogger(__name__)
class AliasViewSet(ListModelMixin, GenericViewSet):
"""Aliases"""
queryset = Alias.objects.all()
serializer_class = AliasSerializer
permission_classes = [IsAuthenticated]
filter_backends = (DjangoFilterBackend, AliasRegexFilterBackend)
filterset_fields = (
"domain",
"domain__name",
)
@action(detail=False, methods=["post"])
def create_bulk(self, request):
serializer = AliasSerializer(data=request.data, many=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(detail=False, methods=["delete"])
def delete_bulk(self, request):
serializer = AliasDeleteSerializer(data=request.data, many=True)
serializer.is_valid(raise_exception=True)
# Ref: http://michelepasin.org/blog/2010/07/20/the-power-of-djangos-q-objects/
q_list = map(lambda x: Q(**x), serializer.initial_data)
aliases = Alias.objects.filter(reduce(operator.or_, q_list))
initial_set = set(map(lambda x: (x["source"], x["destination"], x["domain"]), serializer.initial_data))
deleted_set = set(aliases.values_list("source", "destination", "domain"))
diff = initial_set.difference(deleted_set)
logger.debug("Deleted: {}".format(",".join(map(lambda x: "{0[0]}={0[1]}".format(x), deleted_set))))
aliases.delete()
if diff:
diff_formatted = map(lambda x: "{0[0]}={0[1]}".format(x), diff)
res = "Aliases '{}' could not be found and were not deleted.".format(", ".join(diff_formatted))
logger.info(res)
return Response({"result": res}, status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
class DomainViewSet(viewsets.ModelViewSet):
"""Domains"""
queryset = Domain.objects.all()
serializer_class = DomainSerializer
permission_classes = [IsAuthenticated]
filter_backends = (DjangoFilterBackend,)
filterset_fields = ("name",)
class UserViewSet(viewsets.ModelViewSet):
"""Users"""
queryset = User.objects.all()
serializer_class = UserSerializer
permission_classes = [IsAuthenticated]
View File
Whitespace-only changes.
+51 -30
View File
@@ -1,14 +1,26 @@
""" Ref: https://docs.djangoproject.com/en/3.2/topics/settings/ """ """
For more information on this file, see
https://docs.djangoproject.com/en/stable/topics/settings/
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) For the full list of settings and their values, see
import os https://docs.djangoproject.com/en/stable/ref/settings/
"""
import sentry_sdk from contextlib import suppress
from sentry_sdk.integrations.django import DjangoIntegration from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/stable/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-hlaf#yma(8s!cy-bf5e6kc&_5n_l1h!34c(j32-b3v8o7pg$5n" # noqa: S105
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
SECRET_KEY = os.getenv("SECRET_KEY", "hlaf#yma(8s!cy-bf5e6kc&_5n_l1h!34c(j32-b3v8o7pg$5n")
DEBUG = bool(os.getenv("DEBUG", True))
ALLOWED_HOSTS = [] ALLOWED_HOSTS = []
# Application definition # Application definition
@@ -20,15 +32,16 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"django_extensions", "django_extensions",
"rest_framework", "ninja",
"django_filters",
] ]
INSTALLED_APPS += [ INSTALLED_APPS += [
"dpdapi", "dpdapi",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
@@ -36,7 +49,9 @@ MIDDLEWARE = [
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
] ]
ROOT_URLCONF = "dpdapi.urls"
ROOT_URLCONF = "dpdapisite.urls"
TEMPLATES = [ TEMPLATES = [
{ {
"BACKEND": "django.template.backends.django.DjangoTemplates", "BACKEND": "django.template.backends.django.DjangoTemplates",
@@ -54,25 +69,40 @@ TEMPLATES = [
}, },
}, },
] ]
WSGI_APPLICATION = "dpdapi.wsgi.application"
WSGI_APPLICATION = "dpdapisite.wsgi.application"
# Database
# https://docs.djangoproject.com/en/stable/ref/settings/#databases
DATABASES = { DATABASES = {
"default": { "default": {
"ENGINE": "django.db.backends.sqlite3", "ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(BASE_DIR, "db.sqlite3"), "NAME": BASE_DIR / "db.sqlite3",
} }
} }
# Internationalization
# https://docs.djangoproject.com/en/stable/topics/i18n/
LANGUAGE_CODE = "en-us" LANGUAGE_CODE = "en-us"
TIME_ZONE = "Europe/Oslo" TIME_ZONE = "Europe/Oslo"
USE_I18N = True USE_I18N = True
USE_L10N = True
USE_TZ = True USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/stable/howto/static-files/
STATIC_URL = "/static/" STATIC_URL = "/static/"
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": ( # Postfix and Dovecot read these tables directly; keep the existing integer id columns.
"rest_framework.authentication.BasicAuthentication", DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
"rest_framework.authentication.SessionAuthentication", STATIC_ROOT = BASE_DIR / "static"
) STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
} }
LOGGING = { LOGGING = {
"version": 1, "version": 1,
"disable_existing_loggers": False, "disable_existing_loggers": False,
@@ -93,14 +123,5 @@ LOGGING = {
}, },
} }
SENTRY_DSN = os.getenv("SENTRY_DSN", "https://c7e6a47844484c0a81a98e10e48c1b71@sentry.neuf.no/8") with suppress(ImportError):
if SENTRY_DSN: from .local_settings import * # noqa: F403
SENTRY_ENVIRONMENT = os.getenv("SENTRY_ENVIRONMENT", "production")
sentry_sdk.init(dsn=SENTRY_DSN, environment=SENTRY_ENVIRONMENT, integrations=[DjangoIntegration()])
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
try:
from .local import * # noqa
except ImportError:
pass
+44
View File
@@ -0,0 +1,44 @@
"""Settings for the container image, from environment variables."""
import os
from dpdapisite.settings import * # noqa: F403
DEBUG = False
SECRET_KEY = os.environ["SECRET_KEY"]
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "").split(",")
CSRF_TRUSTED_ORIGINS = [f"https://{host}" for host in ALLOWED_HOSTS if host]
# A DB_HOST starting with / is a unix socket
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": os.getenv("DB_NAME", "prod_mail"),
"USER": os.getenv("DB_USER", "mailserver"),
"PASSWORD": os.environ["DB_PASSWORD"],
"HOST": os.getenv("DB_HOST", "/run/mysqld/mysqld.sock"),
"CONN_MAX_AGE": 60,
"CONN_HEALTH_CHECKS": True,
},
}
# TLS ends at the host's nginx
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
if dsn := os.getenv("SENTRY_DSN"):
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
sentry_sdk.init(
dsn=dsn,
environment=os.getenv("SENTRY_ENVIRONMENT", "production"),
release=os.getenv("SENTRY_RELEASE", "N/A"),
integrations=[DjangoIntegration()],
send_default_pii=False,
traces_sample_rate=0,
send_client_reports=False,
auto_session_tracking=False,
)
+11
View File
@@ -0,0 +1,11 @@
from django.contrib import admin
from django.urls import path
from django.views.generic import RedirectView
from dpdapi.api import api
urlpatterns = [
path("", RedirectView.as_view(pattern_name="api:openapi-view")),
path("api/", api.urls),
path("admin/", admin.site.urls),
]
+1 -1
View File
@@ -2,6 +2,6 @@ import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dpdapi.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dpdapisite.settings")
application = get_wsgi_application() application = get_wsgi_application()
Vendored
-22
View File
@@ -1,22 +0,0 @@
import os
from fabric import Connection
from invoke import task
@task()
def deploy(c):
"""Make sure proxy_user is set to your neuf username."""
project_path = '/var/www/neuf.no/django-postfix-dovecot-api'
proxy_user = os.getenv('DEPLOY_USER', os.getenv('USER'))
c = Connection(host='gitdeploy@lynx.neuf.no', gateway=Connection('login.neuf.no', user=proxy_user))
with c.cd(project_path), c.prefix('source {}/venv/bin/activate'.format(project_path)):
c.run('git pull') # Get source
c.run('pip install -r requirements.txt') # install deps in virtualenv
c.run('umask 022; python manage.py collectstatic --noinput') # Collect static
c.run('python manage.py migrate') # Run DB migrations
# Reload mxapi.neuf.no
c.sudo('/usr/bin/supervisorctl pid mxapi.neuf.no | xargs kill -HUP', shell=False)
+1 -1
View File
@@ -3,7 +3,7 @@ import os
import sys import sys
if __name__ == "__main__": if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dpdapi.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dpdapisite.settings")
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
+7
View File
@@ -0,0 +1,7 @@
[tools]
python = "3.14"
uv = "latest"
prek = "latest"
[env]
_.python.venv = { path = ".venv", create = false }
Generated
-991
View File
@@ -1,991 +0,0 @@
[[package]]
name = "asgiref"
version = "3.5.2"
description = "ASGI specs, helper code, and adapters"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"]
[[package]]
name = "atomicwrites"
version = "1.4.1"
description = "Atomic file writes."
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "attrs"
version = "22.1.0"
description = "Classes Without Boilerplate"
category = "dev"
optional = false
python-versions = ">=3.5"
[package.extras]
dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "cloudpickle"]
[[package]]
name = "bcrypt"
version = "3.2.2"
description = "Modern password hashing for your software and your servers"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
cffi = ">=1.1"
[package.extras]
tests = ["pytest (>=3.2.1,!=3.3.0)"]
typecheck = ["mypy"]
[[package]]
name = "black"
version = "22.6.0"
description = "The uncompromising code formatter."
category = "dev"
optional = false
python-versions = ">=3.6.2"
[package.dependencies]
click = ">=8.0.0"
mypy-extensions = ">=0.4.3"
pathspec = ">=0.9.0"
platformdirs = ">=2"
tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""}
typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}
[package.extras]
colorama = ["colorama (>=0.4.3)"]
d = ["aiohttp (>=3.7.4)"]
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "certifi"
version = "2022.6.15"
description = "Python package for providing Mozilla's CA Bundle."
category = "main"
optional = false
python-versions = ">=3.6"
[[package]]
name = "cffi"
version = "1.15.1"
description = "Foreign Function Interface for Python calling C code."
category = "dev"
optional = false
python-versions = "*"
[package.dependencies]
pycparser = "*"
[[package]]
name = "cfgv"
version = "3.3.1"
description = "Validate configuration and produce human readable error messages."
category = "dev"
optional = false
python-versions = ">=3.6.1"
[[package]]
name = "charset-normalizer"
version = "2.1.0"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
category = "dev"
optional = false
python-versions = ">=3.6.0"
[package.extras]
unicode_backport = ["unicodedata2"]
[[package]]
name = "click"
version = "8.1.3"
description = "Composable command line interface toolkit"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "codecov"
version = "2.1.12"
description = "Hosted coverage reports for GitHub, Bitbucket and Gitlab"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[package.dependencies]
coverage = "*"
requests = ">=2.7.9"
[[package]]
name = "colorama"
version = "0.4.5"
description = "Cross-platform colored terminal text."
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "coverage"
version = "6.4.3"
description = "Code coverage measurement for Python"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
[package.extras]
toml = ["tomli"]
[[package]]
name = "cryptography"
version = "37.0.4"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
cffi = ">=1.12"
[package.extras]
docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"]
docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"]
pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"]
sdist = ["setuptools_rust (>=0.11.4)"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"]
[[package]]
name = "distlib"
version = "0.3.5"
description = "Distribution utilities"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "django"
version = "3.2.15"
description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design."
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
asgiref = ">=3.3.2,<4"
pytz = "*"
sqlparse = ">=0.2.2"
[package.extras]
argon2 = ["argon2-cffi (>=19.1.0)"]
bcrypt = ["bcrypt"]
[[package]]
name = "django-extensions"
version = "3.2.0"
description = "Extensions for Django"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
Django = ">=3.2"
[[package]]
name = "django-filter"
version = "22.1"
description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
Django = ">=3.2"
[[package]]
name = "django-upgrade"
version = "1.7.0"
description = "Automatically upgrade your Django projects."
category = "dev"
optional = false
python-versions = ">=3.8"
[package.dependencies]
tokenize-rt = ">=4.1.0"
[[package]]
name = "djangorestframework"
version = "3.13.1"
description = "Web APIs for Django, made easy."
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
django = ">=2.2"
pytz = "*"
[[package]]
name = "fabric"
version = "2.7.1"
description = "High level SSH command execution"
category = "dev"
optional = false
python-versions = "*"
[package.dependencies]
invoke = ">=1.3,<2.0"
paramiko = ">=2.4"
pathlib2 = "*"
[package.extras]
pytest = ["mock (>=2.0.0,<3.0)", "pytest (>=3.2.5,<4.0)"]
testing = ["mock (>=2.0.0,<3.0)"]
[[package]]
name = "filelock"
version = "3.7.1"
description = "A platform independent file lock."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"]
testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"]
[[package]]
name = "gunicorn"
version = "20.1.0"
description = "WSGI HTTP Server for UNIX"
category = "main"
optional = false
python-versions = ">=3.5"
[package.extras]
eventlet = ["eventlet (>=0.24.1)"]
gevent = ["gevent (>=1.4.0)"]
setproctitle = ["setproctitle"]
tornado = ["tornado (>=0.2)"]
[[package]]
name = "identify"
version = "2.5.3"
description = "File identification library for Python"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.extras]
license = ["ukkonen"]
[[package]]
name = "idna"
version = "3.3"
description = "Internationalized Domain Names in Applications (IDNA)"
category = "dev"
optional = false
python-versions = ">=3.5"
[[package]]
name = "iniconfig"
version = "1.1.1"
description = "iniconfig: brain-dead simple config-ini parsing"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "invoke"
version = "1.7.1"
description = "Pythonic task execution"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "isort"
version = "5.10.1"
description = "A Python utility / library to sort Python imports."
category = "dev"
optional = false
python-versions = ">=3.6.1,<4.0"
[package.extras]
pipfile_deprecated_finder = ["pipreqs", "requirementslib"]
requirements_deprecated_finder = ["pipreqs", "pip-api"]
colors = ["colorama (>=0.4.3,<0.5.0)"]
plugins = ["setuptools"]
[[package]]
name = "libcst"
version = "0.4.7"
description = "A concrete syntax tree with AST-like properties for Python 3.5, 3.6, 3.7, 3.8, 3.9, and 3.10 programs."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
pyyaml = ">=5.2"
typing-extensions = ">=3.7.4.2"
typing-inspect = ">=0.4.0"
[package.extras]
dev = ["black (==22.3.0)", "coverage (>=4.5.4)", "fixit (==0.1.1)", "flake8 (>=3.7.8)", "hypothesis (>=4.36.0)", "hypothesmith (>=0.0.4)", "jupyter (>=1.0.0)", "maturin (>=0.8.3,<0.9)", "nbsphinx (>=0.4.2)", "prompt-toolkit (>=2.0.9)", "setuptools-scm (>=6.0.1)", "sphinx-rtd-theme (>=0.4.3)", "ufmt (==1.3)", "usort (==1.0.0rc1)", "setuptools-rust (>=0.12.1)", "slotscheck (>=0.7.1)", "jinja2 (==3.0.3)", "pyre-check (==0.9.9)"]
[[package]]
name = "mypy-extensions"
version = "0.4.3"
description = "Experimental type system extensions for programs checked with the mypy typechecker."
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "mysqlclient"
version = "2.1.1"
description = "Python interface to MySQL"
category = "main"
optional = false
python-versions = ">=3.5"
[[package]]
name = "nodeenv"
version = "1.7.0"
description = "Node.js virtual environment builder"
category = "dev"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*"
[[package]]
name = "packaging"
version = "21.3"
description = "Core utilities for Python packages"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
[[package]]
name = "paramiko"
version = "2.11.0"
description = "SSH2 protocol library"
category = "dev"
optional = false
python-versions = "*"
[package.dependencies]
bcrypt = ">=3.1.3"
cryptography = ">=2.5"
pynacl = ">=1.0.1"
six = "*"
[package.extras]
all = ["pyasn1 (>=0.1.7)", "pynacl (>=1.0.1)", "bcrypt (>=3.1.3)", "invoke (>=1.3)", "gssapi (>=1.4.1)", "pywin32 (>=2.1.8)"]
ed25519 = ["pynacl (>=1.0.1)", "bcrypt (>=3.1.3)"]
gssapi = ["pyasn1 (>=0.1.7)", "gssapi (>=1.4.1)", "pywin32 (>=2.1.8)"]
invoke = ["invoke (>=1.3)"]
[[package]]
name = "passlib"
version = "1.7.4"
description = "comprehensive password hashing framework supporting over 30 schemes"
category = "main"
optional = false
python-versions = "*"
[package.extras]
argon2 = ["argon2-cffi (>=18.2.0)"]
bcrypt = ["bcrypt (>=3.1.0)"]
build_docs = ["sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)", "cloud-sptheme (>=1.10.1)"]
totp = ["cryptography"]
[[package]]
name = "pathlib2"
version = "2.3.7.post1"
description = "Object-oriented filesystem paths"
category = "dev"
optional = false
python-versions = "*"
[package.dependencies]
six = "*"
[[package]]
name = "pathspec"
version = "0.9.0"
description = "Utility library for gitignore style pattern matching of file paths."
category = "dev"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
[[package]]
name = "platformdirs"
version = "2.5.2"
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"]
test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"]
[[package]]
name = "pluggy"
version = "1.0.0"
description = "plugin and hook calling mechanisms for python"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "pre-commit"
version = "2.20.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
cfgv = ">=2.0.0"
identify = ">=1.0.0"
nodeenv = ">=0.11.1"
pyyaml = ">=5.1"
toml = "*"
virtualenv = ">=20.0.8"
[[package]]
name = "py"
version = "1.11.0"
description = "library with cross-python path, ini-parsing, io, code, log facilities"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "pycln"
version = "2.1.1"
description = "A formatter for finding and removing unused import statements."
category = "dev"
optional = false
python-versions = ">=3.6.2,<4"
[package.dependencies]
libcst = ">=0.3.10,<0.5.0"
pathspec = ">=0.9.0,<0.10.0"
pyyaml = ">=5.3.1,<7.0.0"
tomlkit = ">=0.11.1,<0.12.0"
typer = ">=0.4.1,<0.7.0"
[[package]]
name = "pycparser"
version = "2.21"
description = "C parser in Python"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "pynacl"
version = "1.5.0"
description = "Python binding to the Networking and Cryptography (NaCl) library"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
cffi = ">=1.4.1"
[package.extras]
docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"]
tests = ["pytest (>=3.2.1,!=3.3.0)", "hypothesis (>=3.27.0)"]
[[package]]
name = "pyparsing"
version = "3.0.9"
description = "pyparsing module - Classes and methods to define and execute parsing grammars"
category = "dev"
optional = false
python-versions = ">=3.6.8"
[package.extras]
diagrams = ["railroad-diagrams", "jinja2"]
[[package]]
name = "pytest"
version = "7.1.2"
description = "pytest: simple powerful testing with Python"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
attrs = ">=19.2.0"
colorama = {version = "*", markers = "sys_platform == \"win32\""}
iniconfig = "*"
packaging = "*"
pluggy = ">=0.12,<2.0"
py = ">=1.8.2"
tomli = ">=1.0.0"
[package.extras]
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
[[package]]
name = "pytest-cov"
version = "3.0.0"
description = "Pytest plugin for measuring coverage."
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
coverage = {version = ">=5.2.1", extras = ["toml"]}
pytest = ">=4.6"
[package.extras]
testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"]
[[package]]
name = "pytest-django"
version = "4.5.2"
description = "A Django plugin for pytest."
category = "dev"
optional = false
python-versions = ">=3.5"
[package.dependencies]
pytest = ">=5.4.0"
[package.extras]
docs = ["sphinx", "sphinx-rtd-theme"]
testing = ["django", "django-configurations (>=2.0)"]
[[package]]
name = "pytz"
version = "2022.1"
description = "World timezone definitions, modern and historical"
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "pyupgrade"
version = "2.37.3"
description = "A tool to automatically upgrade syntax for newer versions."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
tokenize-rt = ">=3.2.0"
[[package]]
name = "pyyaml"
version = "6.0"
description = "YAML parser and emitter for Python"
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]]
name = "requests"
version = "2.28.1"
description = "Python HTTP for Humans."
category = "dev"
optional = false
python-versions = ">=3.7, <4"
[package.dependencies]
certifi = ">=2017.4.17"
charset-normalizer = ">=2,<3"
idna = ">=2.5,<4"
urllib3 = ">=1.21.1,<1.27"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "sentry-sdk"
version = "1.9.2"
description = "Python client for Sentry (https://sentry.io)"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
certifi = "*"
urllib3 = [
{version = ">=1.26.9", markers = "python_version >= \"3.5\""},
{version = ">=1.26.11", markers = "python_version >= \"3.6\""},
]
[package.extras]
aiohttp = ["aiohttp (>=3.5)"]
beam = ["apache-beam (>=2.12)"]
bottle = ["bottle (>=0.12.13)"]
celery = ["celery (>=3)"]
chalice = ["chalice (>=1.16.0)"]
django = ["django (>=1.8)"]
falcon = ["falcon (>=1.4)"]
fastapi = ["fastapi (>=0.79.0)"]
flask = ["flask (>=0.11)", "blinker (>=1.1)"]
httpx = ["httpx (>=0.16.0)"]
pure_eval = ["pure-eval", "executing", "asttokens"]
pyspark = ["pyspark (>=2.4.4)"]
quart = ["quart (>=0.16.1)", "blinker (>=1.1)"]
rq = ["rq (>=0.6)"]
sanic = ["sanic (>=0.8)"]
sqlalchemy = ["sqlalchemy (>=1.2)"]
starlette = ["starlette (>=0.19.1)"]
tornado = ["tornado (>=5)"]
[[package]]
name = "six"
version = "1.16.0"
description = "Python 2 and 3 compatibility utilities"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "sqlparse"
version = "0.4.2"
description = "A non-validating SQL parser."
category = "main"
optional = false
python-versions = ">=3.5"
[[package]]
name = "tokenize-rt"
version = "4.2.1"
description = "A wrapper around the stdlib `tokenize` which roundtrips."
category = "dev"
optional = false
python-versions = ">=3.6.1"
[[package]]
name = "toml"
version = "0.10.2"
description = "Python Library for Tom's Obvious, Minimal Language"
category = "dev"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "tomli"
version = "2.0.1"
description = "A lil' TOML parser"
category = "dev"
optional = false
python-versions = ">=3.7"
[[package]]
name = "tomlkit"
version = "0.11.1"
description = "Style preserving TOML library"
category = "dev"
optional = false
python-versions = ">=3.6,<4.0"
[[package]]
name = "typer"
version = "0.6.1"
description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
click = ">=7.1.1,<9.0.0"
[package.extras]
all = ["colorama (>=0.4.3,<0.5.0)", "shellingham (>=1.3.0,<2.0.0)", "rich (>=10.11.0,<13.0.0)"]
dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "pre-commit (>=2.17.0,<3.0.0)"]
doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)"]
test = ["shellingham (>=1.3.0,<2.0.0)", "pytest (>=4.4.0,<5.4.0)", "pytest-cov (>=2.10.0,<3.0.0)", "coverage (>=5.2,<6.0)", "pytest-xdist (>=1.32.0,<2.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "mypy (==0.910)", "black (>=22.3.0,<23.0.0)", "isort (>=5.0.6,<6.0.0)", "rich (>=10.11.0,<13.0.0)"]
[[package]]
name = "typing-extensions"
version = "4.3.0"
description = "Backported and Experimental Type Hints for Python 3.7+"
category = "dev"
optional = false
python-versions = ">=3.7"
[[package]]
name = "typing-inspect"
version = "0.7.1"
description = "Runtime inspection utilities for typing module."
category = "dev"
optional = false
python-versions = "*"
[package.dependencies]
mypy-extensions = ">=0.3.0"
typing-extensions = ">=3.7.4"
[[package]]
name = "urllib3"
version = "1.26.11"
description = "HTTP library with thread-safe connection pooling, file post, and more."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4"
[package.extras]
brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"]
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[[package]]
name = "virtualenv"
version = "20.16.3"
description = "Virtual Python Environment builder"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
distlib = ">=0.3.5,<1"
filelock = ">=3.4.1,<4"
platformdirs = ">=2.4,<3"
[package.extras]
docs = ["proselint (>=0.13)", "sphinx (>=5.1.1)", "sphinx-argparse (>=0.3.1)", "sphinx-rtd-theme (>=1)", "towncrier (>=21.9)"]
testing = ["coverage (>=6.2)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=21.3)", "pytest (>=7.0.1)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.6.1)", "pytest-randomly (>=3.10.3)", "pytest-timeout (>=2.1)"]
[metadata]
lock-version = "1.1"
python-versions = "^3.8"
content-hash = "c0829f4c7197c9f5658a2b3ea22927240b0e53c0948a27d109314d338b808c1d"
[metadata.files]
asgiref = [
{file = "asgiref-3.5.2-py3-none-any.whl", hash = "sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4"},
{file = "asgiref-3.5.2.tar.gz", hash = "sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424"},
]
atomicwrites = []
attrs = []
bcrypt = []
black = []
certifi = [
{file = "certifi-2022.6.15-py3-none-any.whl", hash = "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"},
{file = "certifi-2022.6.15.tar.gz", hash = "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d"},
]
cffi = []
cfgv = [
{file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"},
{file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"},
]
charset-normalizer = []
click = [
{file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
{file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
]
codecov = [
{file = "codecov-2.1.12-py2.py3-none-any.whl", hash = "sha256:585dc217dc3d8185198ceb402f85d5cb5dbfa0c5f350a5abcdf9e347776a5b47"},
{file = "codecov-2.1.12-py3.8.egg", hash = "sha256:782a8e5352f22593cbc5427a35320b99490eb24d9dcfa2155fd99d2b75cfb635"},
{file = "codecov-2.1.12.tar.gz", hash = "sha256:a0da46bb5025426da895af90938def8ee12d37fcbcbbbc15b6dc64cf7ebc51c1"},
]
colorama = [
{file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"},
{file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"},
]
coverage = []
cryptography = []
distlib = []
django = []
django-extensions = []
django-filter = []
django-upgrade = []
djangorestframework = [
{file = "djangorestframework-3.13.1-py3-none-any.whl", hash = "sha256:24c4bf58ed7e85d1fe4ba250ab2da926d263cd57d64b03e8dcef0ac683f8b1aa"},
{file = "djangorestframework-3.13.1.tar.gz", hash = "sha256:0c33407ce23acc68eca2a6e46424b008c9c02eceb8cf18581921d0092bc1f2ee"},
]
fabric = []
filelock = [
{file = "filelock-3.7.1-py3-none-any.whl", hash = "sha256:37def7b658813cda163b56fc564cdc75e86d338246458c4c28ae84cabefa2404"},
{file = "filelock-3.7.1.tar.gz", hash = "sha256:3a0fd85166ad9dbab54c9aec96737b744106dc5f15c0b09a6744a445299fcf04"},
]
gunicorn = [
{file = "gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e"},
{file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"},
]
identify = []
idna = [
{file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
{file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
]
iniconfig = [
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
]
invoke = []
isort = [
{file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"},
{file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"},
]
libcst = []
mypy-extensions = [
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
]
mysqlclient = []
nodeenv = []
packaging = [
{file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
{file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
]
paramiko = []
passlib = [
{file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"},
{file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"},
]
pathlib2 = [
{file = "pathlib2-2.3.7.post1-py2.py3-none-any.whl", hash = "sha256:5266a0fd000452f1b3467d782f079a4343c63aaa119221fbdc4e39577489ca5b"},
{file = "pathlib2-2.3.7.post1.tar.gz", hash = "sha256:9fe0edad898b83c0c3e199c842b27ed216645d2e177757b2dd67384d4113c641"},
]
pathspec = [
{file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
{file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
]
platformdirs = [
{file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"},
{file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"},
]
pluggy = [
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
]
pre-commit = []
py = [
{file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
{file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
]
pycln = []
pycparser = [
{file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
{file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
]
pynacl = [
{file = "PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1"},
{file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92"},
{file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394"},
{file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d"},
{file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858"},
{file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b"},
{file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff"},
{file = "PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543"},
{file = "PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93"},
{file = "PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba"},
]
pyparsing = [
{file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
{file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
]
pytest = [
{file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"},
{file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"},
]
pytest-cov = []
pytest-django = [
{file = "pytest-django-4.5.2.tar.gz", hash = "sha256:d9076f759bb7c36939dbdd5ae6633c18edfc2902d1a69fdbefd2426b970ce6c2"},
{file = "pytest_django-4.5.2-py3-none-any.whl", hash = "sha256:c60834861933773109334fe5a53e83d1ef4828f2203a1d6a0fa9972f4f75ab3e"},
]
pytz = [
{file = "pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"},
{file = "pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7"},
]
pyupgrade = []
pyyaml = [
{file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"},
{file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"},
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"},
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"},
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"},
{file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"},
{file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"},
{file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"},
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"},
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"},
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"},
{file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"},
{file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"},
{file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"},
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"},
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"},
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"},
{file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"},
{file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"},
{file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"},
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"},
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"},
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"},
{file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"},
{file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"},
{file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"},
{file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"},
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"},
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"},
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"},
{file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"},
{file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"},
{file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
]
requests = []
sentry-sdk = []
six = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
sqlparse = [
{file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"},
{file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"},
]
tokenize-rt = [
{file = "tokenize_rt-4.2.1-py2.py3-none-any.whl", hash = "sha256:08a27fa032a81cf45e8858d0ac706004fcd523e8463415ddf1442be38e204ea8"},
{file = "tokenize_rt-4.2.1.tar.gz", hash = "sha256:0d4f69026fed520f8a1e0103aa36c406ef4661417f20ca643f913e33531b3b94"},
]
toml = [
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
]
tomli = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
]
tomlkit = []
typer = []
typing-extensions = []
typing-inspect = [
{file = "typing_inspect-0.7.1-py2-none-any.whl", hash = "sha256:b1f56c0783ef0f25fb064a01be6e5407e54cf4a4bf4f3ba3fe51e0bd6dcea9e5"},
{file = "typing_inspect-0.7.1-py3-none-any.whl", hash = "sha256:3cd7d4563e997719a710a3bfe7ffb544c6b72069b6812a02e9b414a8fa3aaa6b"},
{file = "typing_inspect-0.7.1.tar.gz", hash = "sha256:047d4097d9b17f46531bf6f014356111a1b6fb821a24fe7ac909853ca2a782aa"},
]
urllib3 = []
virtualenv = []
+39
View File
@@ -0,0 +1,39 @@
#:schema https://www.schemastore.org/prek.json
# Git hooks, run by prek (https://prek.j178.dev). `prek install` once per clone; CI runs
# them all (.gitea/workflows/ci.yaml).
[[repos]]
repo = "builtin"
hooks = [
{ id = "trailing-whitespace" },
{ id = "end-of-file-fixer" },
{ id = "mixed-line-ending", args = ["--fix", "lf"] },
{ id = "fix-byte-order-marker" },
{ id = "check-added-large-files" },
{ id = "check-case-conflict" },
{ id = "check-merge-conflict" },
{ id = "check-executables-have-shebangs" },
{ id = "check-shebang-scripts-are-executable" },
{ id = "check-symlinks" },
{ id = "check-toml" },
{ id = "check-yaml" },
{ id = "detect-private-key" },
]
[[repos]]
repo = "https://github.com/astral-sh/ruff-pre-commit"
rev = "v0.17.3" # keep in sync with ruff in uv.lock
hooks = [
{ id = "ruff-check", args = ["--fix"] },
{ id = "ruff-format" },
]
[[repos]]
repo = "https://github.com/adamchainz/django-upgrade"
rev = "1.33.0"
hooks = [{ id = "django-upgrade", args = ["--target-version", "5.2"] }]
[[repos]]
repo = "https://github.com/astral-sh/uv-pre-commit"
rev = "0.13.6"
hooks = [{ id = "uv-lock" }] # uv.lock stays in sync with pyproject.toml
+53 -41
View File
@@ -1,47 +1,59 @@
[tool.pytest.ini_options] [project]
DJANGO_SETTINGS_MODULE = "dpdapi.settings"
# FIXME: https://github.com/django-extensions/django-extensions/pull/1741
filterwarnings = "ignore::PendingDeprecationWarning:django.apps.registry.*"
[tool.black]
line-length = 120
target_version = ['py38']
[tool.isort]
profile = "black"
line_length = 120
[tool.poetry]
name = "django-postfix-dovecot-api" name = "django-postfix-dovecot-api"
version = "0.1.0" version = "0.1.0"
description = "" description = "REST API for managing Postfix/Dovecot virtual domains, users and aliases"
authors = ["EDB <edb@neuf.no>"] readme = "README.md"
requires-python = "~=3.14.0"
dependencies = [
"django>=5.2,<6",
"django-extensions>=4.1",
"django-ninja>=1.7.3",
"libpass>=1.9.4",
"sentry-sdk>=2.73.0",
"whitenoise>=6.12.0",
]
[tool.poetry.dependencies] [dependency-groups]
python = "^3.8" dev = [
"pytest>=9.2.0",
"pytest-cov>=7.1.0",
"pytest-django>=4.14.0",
"ruff>=0.17.3",
]
prod = [
"gunicorn>=26.2.0",
"mysqlclient>=2.3.0",
]
Django="3.2.*" [tool.uv]
django-extensions="3.2.*" package = false
django-filter="22.1"
djangorestframework="3.13.*"
gunicorn="20.1.*"
mysqlclient="2.1.*"
passlib="1.7.*"
sentry-sdk="1.9.*"
[tool.poetry.dev-dependencies] [tool.pytest.ini_options]
black = "22.6.*" DJANGO_SETTINGS_MODULE = "dpdapisite.settings"
codecov = "2.1.*" testpaths = ["tests"]
django-upgrade = "^1.7.0" pythonpath = ["."]
fabric="2.7.*" filterwarnings = ["error"]
isort = "*" addopts = "--cov --cov-report=term-missing"
pre-commit = "*"
pycln = "2.1.*"
pytest = "*"
pytest-cov = "^3.0.0"
pytest-django = "*"
pyupgrade = "2.37.*"
[build-system] [tool.coverage.run]
requires = ["poetry-core>=1.0.0"] source = ["dpdapi", "dpdapisite"]
build-backend = "poetry.core.masonry.api" omit = ["*/migrations/*", "dpdapisite/settings.py", "dpdapisite/settings_prod.py"]
branch = true
[tool.coverage.report]
fail_under = 100
show_missing = true
[tool.ruff]
line-length = 120
target-version = "py314"
extend-exclude = ["*/migrations/*"]
force-exclude = true
[tool.ruff.lint]
select = [
"E", "W", "F", "I", "UP", "B", "A", "C4", "DJ", "S", "SIM", "PT", "PTH", "RET", "RUF", "N", "LOG", "G",
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101", "S105", "S106"]
View File
Whitespace-only changes.
+34
View File
@@ -0,0 +1,34 @@
import base64
import pytest
@pytest.fixture(autouse=True)
def static_files(settings, tmp_path):
"""The manifest storage needs collectstatic, which tests don't run."""
settings.STATIC_ROOT = tmp_path / "static"
settings.STATIC_ROOT.mkdir()
settings.STORAGES = {
**settings.STORAGES,
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
}
@pytest.fixture
def admin(django_user_model):
return django_user_model.objects.create_superuser("admin", "admin@example.com", "secret")
@pytest.fixture
def api(client, admin):
"""A Django test client authenticated with HTTP Basic auth."""
token = base64.b64encode(b"admin:secret").decode()
client.defaults["HTTP_AUTHORIZATION"] = f"Basic {token}"
return client
@pytest.fixture
def domain(db):
from dpdapi.models import Domain
return Domain.objects.create(name="example.com")
+160
View File
@@ -0,0 +1,160 @@
import pytest
from dpdapi.models import Alias, Domain
FIELDS = {"id", "created", "updated", "source", "destination", "domain", "active"}
@pytest.fixture
def aliases(domain):
other = Domain.objects.create(name="other.org")
return [
Alias.objects.create(source="ops-alert@example.com", destination="ninja@example.com", domain=domain),
Alias.objects.create(source="yolo-announce@example.com", destination="ninja@example.com", domain=domain),
Alias.objects.create(source="Ops@Other.org", destination="Boss@Other.org", domain=other),
]
def sources(res):
assert res.status_code == 200
return sorted(a["source"] for a in res.json())
def test_list(api, aliases):
res = api.get("/api/aliases/")
assert res.status_code == 200
body = res.json()
assert len(body) == 3
assert set(body[0]) == FIELDS
first = next(a for a in body if a["id"] == aliases[0].pk)
assert first["source"] == "ops-alert@example.com"
assert first["destination"] == "ninja@example.com"
assert first["domain"] == aliases[0].domain_id
assert first["active"] is True
def test_addresses_are_lowercased(aliases):
assert aliases[2].source == "ops@other.org"
assert aliases[2].destination == "boss@other.org"
assert str(aliases[2]) == "ops@other.org=boss@other.org"
@pytest.mark.parametrize(
("params", "expected"),
[
({"domain__name": "example.com"}, ["ops-alert@example.com", "yolo-announce@example.com"]),
({"domain__name": "nope.com"}, []),
({"source__regex": "^ops-|^ops@"}, ["ops-alert@example.com", "ops@other.org"]),
({"source__regex": "^OPS"}, []),
({"source__iregex": "^OPS"}, ["ops-alert@example.com", "ops@other.org"]),
({"destination__regex": "^boss"}, ["ops@other.org"]),
({"destination__iregex": "^NINJA"}, ["ops-alert@example.com", "yolo-announce@example.com"]),
({"domain__name": "example.com", "source__regex": "^yolo"}, ["yolo-announce@example.com"]),
],
)
def test_filters(api, aliases, params, expected):
assert sources(api.get("/api/aliases/", params)) == expected
def test_filter_by_domain_id(api, aliases):
res = api.get("/api/aliases/", {"domain": aliases[2].domain_id})
assert sources(res) == ["ops@other.org"]
def test_create_bulk(api, domain):
data = [
{"source": "A@example.com", "destination": "b@example.com", "domain": domain.pk},
{"source": "c@example.com", "destination": "d@example.com", "domain": domain.pk, "active": False},
]
res = api.post("/api/aliases/create_bulk/", data, content_type="application/json")
assert res.status_code == 201
body = res.json()
assert [(a["source"], a["destination"], a["active"]) for a in body] == [
("a@example.com", "b@example.com", True),
("c@example.com", "d@example.com", False),
]
assert all(set(a) == FIELDS for a in body)
assert Alias.objects.count() == 2
def test_create_bulk_accepts_existing(api, aliases):
existing = aliases[0]
data = [{"source": existing.source, "destination": existing.destination, "domain": existing.domain_id}]
res = api.post("/api/aliases/create_bulk/", data, content_type="application/json")
assert res.status_code == 201
assert res.json()[0]["id"] == existing.pk
assert Alias.objects.count() == 3
@pytest.mark.parametrize(
"item",
[
{"source": "not-an-email", "destination": "b@example.com"},
{"source": "a@example.com"},
{"source": "a@example.com", "destination": "b@example.com", "domain": 999},
],
)
def test_create_bulk_invalid(api, domain, item):
item.setdefault("domain", domain.pk)
res = api.post("/api/aliases/create_bulk/", [item], content_type="application/json")
assert res.status_code == 400
assert Alias.objects.count() == 0
def test_create_bulk_requires_list(api, domain):
data = {"source": "a@example.com", "destination": "b@example.com", "domain": domain.pk}
res = api.post("/api/aliases/create_bulk/", data, content_type="application/json")
assert res.status_code == 400
def test_delete_bulk(api, aliases):
data = [{"source": a.source, "destination": a.destination, "domain": a.domain_id} for a in aliases[:2]]
res = api.delete("/api/aliases/delete_bulk/", data, content_type="application/json")
assert res.status_code == 204
assert list(Alias.objects.all()) == [aliases[2]]
def test_delete_bulk_empty(api, aliases):
res = api.delete("/api/aliases/delete_bulk/", [], content_type="application/json")
assert res.status_code == 204
assert Alias.objects.count() == 3
def test_delete_bulk_reports_missing(api, aliases):
data = [
{"source": aliases[0].source, "destination": aliases[0].destination, "domain": aliases[0].domain_id},
{"source": "ghost@example.com", "destination": "ninja@example.com", "domain": aliases[0].domain_id},
]
res = api.delete("/api/aliases/delete_bulk/", data, content_type="application/json")
assert res.status_code == 200
assert res.json() == {
"result": "Aliases 'ghost@example.com=ninja@example.com' could not be found and were not deleted."
}
assert Alias.objects.count() == 2
def test_delete_bulk_only_matches_exact_alias(api, aliases):
"""Extra keys must not widen the match to other aliases."""
data = [
{
"source": "ghost@example.com",
"destination": "ninja@example.com",
"domain": aliases[0].domain_id,
"source__regex": ".*",
}
]
res = api.delete("/api/aliases/delete_bulk/", data, content_type="application/json")
assert res.status_code == 200
assert Alias.objects.count() == 3
def test_delete_bulk_invalid(api, aliases):
res = api.delete("/api/aliases/delete_bulk/", [{"source": "x"}], content_type="application/json")
assert res.status_code == 400
assert Alias.objects.count() == 3
@pytest.mark.parametrize("method", ["post", "put", "patch"])
def test_alias_detail_routes_do_not_exist(api, aliases, method):
res = getattr(api, method)("/api/aliases/", {}, content_type="application/json")
assert res.status_code == 405
+48
View File
@@ -0,0 +1,48 @@
import pytest
ENDPOINTS = [
("get", "/api/aliases/"),
("post", "/api/aliases/create_bulk/"),
("delete", "/api/aliases/delete_bulk/"),
("get", "/api/domains/"),
("post", "/api/domains/"),
("get", "/api/domains/1/"),
("put", "/api/domains/1/"),
("patch", "/api/domains/1/"),
("delete", "/api/domains/1/"),
("get", "/api/users/"),
("post", "/api/users/"),
("get", "/api/users/1/"),
("put", "/api/users/1/"),
("patch", "/api/users/1/"),
("delete", "/api/users/1/"),
]
@pytest.mark.django_db
@pytest.mark.parametrize(("method", "url"), ENDPOINTS)
def test_anonymous_is_rejected(client, method, url):
res = getattr(client, method)(url, content_type="application/json")
assert res.status_code == 401
@pytest.mark.django_db
def test_wrong_password_is_rejected(client, admin):
client.defaults["HTTP_AUTHORIZATION"] = "Basic YWRtaW46d3Jvbmc=" # admin:wrong
assert client.get("/api/domains/").status_code == 401
def test_basic_auth(api):
assert api.get("/api/domains/").status_code == 200
def test_session_auth(client, admin):
client.force_login(admin)
assert client.get("/api/domains/").status_code == 200
def test_root_redirects_to_api(client, admin):
client.force_login(admin)
res = client.get("/", follow=True)
assert res.redirect_chain
assert res.status_code == 200
+98
View File
@@ -0,0 +1,98 @@
import pytest
from django.utils import timezone
from dpdapi.models import Domain
FIELDS = {"id", "created", "updated", "name"}
def test_list(api, domain):
Domain.objects.create(name="other.org")
res = api.get("/api/domains/")
assert res.status_code == 200
assert sorted(d["name"] for d in res.json()) == ["example.com", "other.org"]
assert set(res.json()[0]) == FIELDS
def test_filter_by_name(api, domain):
Domain.objects.create(name="other.org")
res = api.get("/api/domains/", {"name": "other.org"})
assert [d["name"] for d in res.json()] == ["other.org"]
def test_retrieve(api, domain):
res = api.get(f"/api/domains/{domain.pk}/")
assert res.status_code == 200
assert res.json()["name"] == "example.com"
assert res.json()["id"] == domain.pk
def test_timestamps_are_local_iso8601(api, domain):
body = api.get(f"/api/domains/{domain.pk}/").json()
assert body["created"] == timezone.localtime(domain.created).isoformat()
assert body["updated"] == timezone.localtime(domain.updated).isoformat()
def test_retrieve_missing(api, db):
assert api.get("/api/domains/999/").status_code == 404
def test_create(api, db):
res = api.post("/api/domains/", {"name": "New.ORG"}, content_type="application/json")
assert res.status_code == 201
assert res.json()["name"] == "new.org"
assert set(res.json()) == FIELDS
assert str(Domain.objects.get()) == "new.org"
@pytest.mark.parametrize(
"data", [{}, {"name": ""}, {"name": "example.com"}, {"name": "Example.COM"}, {"name": "x" * 255}]
)
def test_create_invalid(api, domain, data):
res = api.post("/api/domains/", data, content_type="application/json")
assert res.status_code == 400
assert Domain.objects.count() == 1
def test_update(api, domain):
res = api.put(f"/api/domains/{domain.pk}/", {"name": "Renamed.com"}, content_type="application/json")
assert res.status_code == 200
assert res.json()["name"] == "renamed.com"
domain.refresh_from_db()
assert domain.name == "renamed.com"
def test_update_invalid(api, domain):
res = api.put(f"/api/domains/{domain.pk}/", {}, content_type="application/json")
assert res.status_code == 400
def test_partial_update(api, domain):
res = api.patch(f"/api/domains/{domain.pk}/", {"name": "patched.com"}, content_type="application/json")
assert res.status_code == 200
assert res.json()["name"] == "patched.com"
def test_partial_update_empty(api, domain):
res = api.patch(f"/api/domains/{domain.pk}/", {}, content_type="application/json")
assert res.status_code == 200
assert res.json()["name"] == "example.com"
def test_partial_update_null(api, domain):
res = api.patch(f"/api/domains/{domain.pk}/", {"name": None}, content_type="application/json")
assert res.status_code == 400
def test_update_missing(api, db):
res = api.put("/api/domains/999/", {"name": "x.com"}, content_type="application/json")
assert res.status_code == 404
def test_delete(api, domain):
assert api.delete(f"/api/domains/{domain.pk}/").status_code == 204
assert not Domain.objects.exists()
def test_delete_missing(api, db):
assert api.delete("/api/domains/999/").status_code == 404
+61
View File
@@ -0,0 +1,61 @@
from io import StringIO
import pytest
from django.core.management import call_command
from dpdapi.models import Alias, Domain
def run(*args):
out = StringIO()
call_command("import_valiases", *args, stdout=out)
return out.getvalue()
@pytest.mark.django_db
def test_missing_argument():
assert run() == "Missing argument\n"
@pytest.mark.django_db
def test_missing_file(tmp_path):
path = tmp_path / "nope"
assert run(str(path)) == f"{path} does not exist\n"
@pytest.mark.django_db
def test_no_aliases(tmp_path):
path = tmp_path / "valiases"
path.write_text('*: catchall@example.com\nlist: "|/usr/lib/mailman/mail/mailman post list"\n')
assert run(str(path)) == "No aliases found\n"
assert not Domain.objects.exists()
@pytest.mark.django_db
def test_import(tmp_path):
path = tmp_path / "valiases"
path.write_text(
"*: catchall@example.com\n"
"ops@example.com: a@example.com,b@example.com\n"
'list@example.com: "|/usr/lib/mailman/mail/mailman post list",list-admin@example.com,c@example.com\n'
"ops@example.com: a@example.com\n"
)
out = run(str(path))
assert out == (
"Created domain example.com.\n"
"Could not import 'ops@example.com=a@example.com', already exists.\n"
"Imported 3, skipped 1 existing, total 4\n"
)
assert sorted(Alias.objects.values_list("source", "destination")) == [
("list@example.com", "c@example.com"),
("ops@example.com", "a@example.com"),
("ops@example.com", "b@example.com"),
]
@pytest.mark.django_db
def test_import_into_existing_domain(tmp_path):
Domain.objects.create(name="example.com")
path = tmp_path / "valiases"
path.write_text("ops@example.com: a@example.com\n")
assert run(str(path)) == "Imported 1, skipped 0 existing, total 1\n"
+83
View File
@@ -0,0 +1,83 @@
"""The exact calls mailadmin (https://git.neuf.no/edb/mailadmin, see mailadmin/api.py) makes, and what it reads."""
import pytest
from dpdapi.models import Alias, Domain
# mailadmin's PrefixQueryset.as_regex() for the prefixes "ops" and "yolo"
PREFIX_REGEX = "^ops-|^ops@|^yolo-|^yolo@"
@pytest.fixture
def aliases(domain):
other = Domain.objects.create(name="other.org")
Alias.objects.create(source="ops-alert@example.com", destination="ninja@example.com", domain=domain)
Alias.objects.create(source="yolo@example.com", destination="ninja@example.com", domain=domain)
Alias.objects.create(source="nope-list@example.com", destination="ninja@example.com", domain=domain)
Alias.objects.create(source="ops@other.org", destination="ninja@other.org", domain=other)
def test_list_domains(api, domain):
res = api.get("/api/domains/", {"name": "example.com"})
assert res.status_code == 200
assert [(d["id"], d["name"]) for d in res.json()] == [(domain.pk, "example.com")]
def test_list_aliases_regex(api, aliases):
res = api.get("/api/aliases/", {"domain__name": "example.com", "source__iregex": PREFIX_REGEX})
assert res.status_code == 200
assert sorted(a["source"] for a in res.json()) == ["ops-alert@example.com", "yolo@example.com"]
assert all({"id", "source", "destination"} <= set(a) for a in res.json())
def test_create_aliases(api, domain):
data = [
{"source": "ops-new@example.com", "destination": "a@example.com", "domain": domain.pk},
{"source": "ops-new@example.com", "destination": "B@Example.com", "domain": domain.pk},
]
res = api.post("/api/aliases/create_bulk/", data, content_type="application/json")
assert res.status_code == 201
body = res.json()
assert [(a["source"], a["destination"]) for a in body] == [
("ops-new@example.com", "a@example.com"),
("ops-new@example.com", "b@example.com"),
]
assert [a["id"] for a in body] == list(Alias.objects.order_by("pk").values_list("pk", flat=True))
def test_create_aliases_case_insensitive_duplicate(api, aliases, domain):
data = [{"source": "OPS-alert@example.com", "destination": "Ninja@example.com", "domain": domain.pk}]
res = api.post("/api/aliases/create_bulk/", data, content_type="application/json")
assert res.status_code == 201
assert Alias.objects.count() == 4
def test_delete_aliases(api, aliases, domain):
data = [{"source": "ops-alert@example.com", "destination": "ninja@example.com", "domain": domain.pk}]
res = api.delete("/api/aliases/delete_bulk/", data, content_type="application/json")
assert res.status_code == 204
assert res.content == b"" # mailadmin turns a non-JSON body into None
def test_delete_aliases_partially_missing(api, aliases, domain):
data = [
{"source": "ops-alert@example.com", "destination": "ninja@example.com", "domain": domain.pk},
{"source": "ops-alert@example.com", "destination": "ghost@example.com", "domain": domain.pk},
]
res = api.delete("/api/aliases/delete_bulk/", data, content_type="application/json")
assert res.status_code == 200
assert "ops-alert@example.com=ghost@example.com" in res.json()["result"]
def test_errors_are_json(api, domain):
data = [{"source": "bad", "destination": "a@example.com", "domain": domain.pk}]
res = api.post("/api/aliases/create_bulk/", data, content_type="application/json")
assert res.status_code == 400
assert res.json()
def test_bad_credentials_are_json(client, db):
client.defaults["HTTP_AUTHORIZATION"] = "Basic YWRtaW46d3Jvbmc="
res = client.get("/api/domains/")
assert res.status_code == 401
assert res.json()
+24
View File
@@ -0,0 +1,24 @@
"""Postfix/Dovecot read these tables directly, so their names must never change."""
import pytest
from django.core.management import call_command
from django.db import connection
SCHEMA = {
"dpdapi_domain": {"id", "created", "updated", "name"},
"dpdapi_alias": {"id", "created", "updated", "source", "destination", "active", "domain_id"},
"dpdapi_user": {"id", "created", "updated", "email", "password", "domain_id"},
}
@pytest.mark.django_db
@pytest.mark.parametrize(("table", "columns"), SCHEMA.items())
def test_tables_and_columns(table, columns):
with connection.cursor() as cursor:
description = connection.introspection.get_table_description(cursor, table)
assert {c.name for c in description} == columns
@pytest.mark.django_db
def test_no_pending_migrations():
call_command("makemigrations", "dpdapi", "--check", "--dry-run")
+23
View File
@@ -0,0 +1,23 @@
import pytest
from dpdapi.models import Alias, Domain, User
def test_wsgi_application():
from dpdapisite.wsgi import application
assert callable(application)
@pytest.mark.parametrize("model", [Alias, Domain, User])
def test_admin_changelist(client, admin, domain, model):
client.force_login(admin)
meta = model._meta
res = client.get(f"/admin/{meta.app_label}/{meta.model_name}/", {"q": "example"})
assert res.status_code == 200
def test_sentry_disabled_in_tests():
import sentry_sdk
assert not sentry_sdk.get_client().is_active()
+107
View File
@@ -0,0 +1,107 @@
import pytest
from passlib.hash import sha512_crypt
from dpdapi.models import Domain, User
FIELDS = {"id", "created", "updated", "email", "domain"}
@pytest.fixture
def user(domain):
return User.objects.create(email="Jane@Example.com", password="x", domain=domain)
def test_list(api, user):
res = api.get("/api/users/")
assert res.status_code == 200
assert res.json() == [res.json()[0]]
assert set(res.json()[0]) == FIELDS
assert res.json()[0]["email"] == "jane@example.com"
assert res.json()[0]["domain"] == user.domain_id
def test_retrieve(api, user):
res = api.get(f"/api/users/{user.pk}/")
assert res.status_code == 200
assert set(res.json()) == FIELDS
assert str(user) == "jane@example.com"
def test_retrieve_missing(api, db):
assert api.get("/api/users/999/").status_code == 404
def test_create_hashes_password(api, domain):
data = {"email": "New@Example.com", "password": "hunter2", "domain": domain.pk}
res = api.post("/api/users/", data, content_type="application/json")
assert res.status_code == 201
assert set(res.json()) == FIELDS
assert res.json()["email"] == "new@example.com"
stored = User.objects.get().password
assert stored.startswith("$6$")
assert sha512_crypt.verify("hunter2", stored)
@pytest.mark.parametrize(
"data",
[
{"password": "p"},
{"email": "nope", "password": "p"},
{"email": "jane@example.com", "password": "p"},
{"email": "a@example.com"},
{"email": "a@example.com", "password": "p", "domain": 999},
],
)
def test_create_invalid(api, user, data):
data.setdefault("domain", user.domain_id)
res = api.post("/api/users/", data, content_type="application/json")
assert res.status_code == 400
assert User.objects.count() == 1
def test_update_hashes_password(api, user):
other = Domain.objects.create(name="other.org")
data = {"email": "jane@other.org", "password": "new-secret", "domain": other.pk}
res = api.put(f"/api/users/{user.pk}/", data, content_type="application/json")
assert res.status_code == 200
assert res.json()["email"] == "jane@other.org"
assert res.json()["domain"] == other.pk
user.refresh_from_db()
assert sha512_crypt.verify("new-secret", user.password)
def test_update_invalid(api, user):
res = api.put(f"/api/users/{user.pk}/", {"email": "x@example.com"}, content_type="application/json")
assert res.status_code == 400
def test_partial_update_keeps_password(api, user):
res = api.patch(f"/api/users/{user.pk}/", {"email": "J@example.com"}, content_type="application/json")
assert res.status_code == 200
assert res.json()["email"] == "j@example.com"
user.refresh_from_db()
assert user.password == "x"
def test_partial_update_hashes_password(api, user):
res = api.patch(f"/api/users/{user.pk}/", {"password": "p2"}, content_type="application/json")
assert res.status_code == 200
user.refresh_from_db()
assert sha512_crypt.verify("p2", user.password)
@pytest.mark.parametrize("field", ["email", "password", "domain"])
def test_partial_update_null(api, user, field):
res = api.patch(f"/api/users/{user.pk}/", {field: None}, content_type="application/json")
assert res.status_code == 400
assert field in str(res.json())
def test_delete(api, user):
assert api.delete(f"/api/users/{user.pk}/").status_code == 204
assert not User.objects.exists()
def test_deleting_domain_cascades(api, user):
assert api.delete(f"/api/domains/{user.domain_id}/").status_code == 204
assert not User.objects.exists()
Generated
+428
View File
@@ -0,0 +1,428 @@
version = 1
revision = 5
requires-python = "==3.14.*"
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]]
name = "asgiref"
version = "3.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/90/f5fce14e95fccf53b58e8e2c47cd84d40d105d64073ae1ac75166db150e0/asgiref-3.13.0.tar.gz", hash = "sha256:034cc8cbf72d00553b4be256703ac545120b4e50af13b49dec99da899aeeecb4", size = 52941, upload-time = "2026-10-23T08:13:14.715Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/b0/ec211e1f7966247cb6469158d856bac9aa4c2fe13621bd6a352e4f850453/asgiref-3.13.0-py3-none-any.whl", hash = "sha256:f9596a2434512686473d50f96ff6207fbba49ae1a785dfac614df4aeb085674d", size = 27593, upload-time = "2026-10-23T08:13:13.394Z" },
]
[[package]]
name = "certifi"
version = "2026.10.23"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d2/79/e78bd8f75a9b8e32233ce92d4607f0a21ce88a9156cace2884e7175a82f5/coverage-7.17.0.tar.gz", hash = "sha256:cc010a0a7fbdd9bb8bdfd0d3f04a5151275860213cbd7e2b876b29778489c712", size = 991021, upload-time = "2026-10-22T16:06:50.009Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/e1/722462fe79912ebebc101497bd16c42413cde2dbae771017f1ec0acb84c2/coverage-7.17.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:dc5936491e306f2f6f095bd5fba593de11eb0375cee787ca1f245c3a333a523a", size = 227197, upload-time = "2026-10-22T16:05:09.846Z" },
{ url = "https://files.pythonhosted.org/packages/b7/74/826b9c09f2ebfea803bccb391d935688a25309bff8e819fc446bc629c108/coverage-7.17.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:968c5fb6e0c83cab752d79a70209648ce4d90cae815c60fa229b59e264601062", size = 227314, upload-time = "2026-10-22T16:05:11.422Z" },
{ url = "https://files.pythonhosted.org/packages/6d/19/c8514d7f5e0659a96ff2bc7377119dca6d420e2761c5fc69ae37618734f8/coverage-7.17.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:12cb8a20ad904a5811e6660d829a586b216e5254b9b34b76dde8a4d65d827dc6", size = 225477, upload-time = "2026-10-22T16:05:12.761Z" },
{ url = "https://files.pythonhosted.org/packages/81/74/ef9433ce599f07d8633b4ce6e6d6921505c4ef3f7f9006f8eb66e2c278cf/coverage-7.17.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:bb5ca66937c55bc85d1a5e9c5900a3e4693a8edf2280a9b3672b7b2661a84b95", size = 225787, upload-time = "2026-10-22T16:05:14.394Z" },
{ url = "https://files.pythonhosted.org/packages/d8/66/26e58981940e8b3944c6a9941bec5c7f50840e7009a046712e3f3bcd9e6a/coverage-7.17.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5cc15faaf318ee5040d835a62c75bc6f122d837274f3b5efd47bf9fbd4ac5b85", size = 226019, upload-time = "2026-10-22T16:05:15.798Z" },
{ url = "https://files.pythonhosted.org/packages/e9/63/fe175310815cc8357b83b773c9b4c20943c269f217dd825a37c4b4bfe324/coverage-7.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d6d9d396c8b7bcaf76d774a10140b218111484e54c73a5f2a1b75655381748e", size = 226169, upload-time = "2026-10-22T16:05:17.188Z" },
{ url = "https://files.pythonhosted.org/packages/2c/b0/ac84761f5b4bd226696b0ec4606903d2ef7208abda0aec06c14a4218def4/coverage-7.17.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:42e6f170d1ac04b7eb3363310a6c41cdfd82c1fe6bf9ffbc4a86f380bdf091c7", size = 258092, upload-time = "2026-10-22T16:05:18.691Z" },
{ url = "https://files.pythonhosted.org/packages/e9/58/d8bdd65a661913bc12eaaa3e59742b2cb8b94f2e7f78e62184f0a58ca493/coverage-7.17.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:acdb5f3c96046efff9dc6c572a6acb2b8d0da09372d7b9a9ed7249cf7d17434f", size = 261096, upload-time = "2026-10-22T16:05:20.089Z" },
{ url = "https://files.pythonhosted.org/packages/7b/15/5d82e2f0a75ea149e0a6412e137e1d610538772fdfd7199737db7fe234da/coverage-7.17.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3393209f6894713161ee82a9b0e673e58237e0f9733c6638e7a6c0c620ef0b33", size = 262080, upload-time = "2026-10-22T16:05:21.568Z" },
{ url = "https://files.pythonhosted.org/packages/0f/cc/e3ebfd295d1dd47c7bc84a17d73f150465a37ab388dd68fe82b495dc79a0/coverage-7.17.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9494d9e99389d39e6a96227749af898d8924ff591cb63944f8a98e68582bf0e", size = 264558, upload-time = "2026-10-22T16:05:22.906Z" },
{ url = "https://files.pythonhosted.org/packages/e0/9d/e2adff76d6544bbc6df7366d74967923c2de37ba889a006bfb875bf5857b/coverage-7.17.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:032d4c2db4c677eb71d43f2f8b80bf64e9346f7a3f7b6ebb13f1782f3cc6c274", size = 257855, upload-time = "2026-10-22T16:05:24.343Z" },
{ url = "https://files.pythonhosted.org/packages/d9/fd/ecd1e9cddf1c36b5d03c438568b1f53e63db84bb3656539d71b454484c7d/coverage-7.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4343a9378b964eb3cb80fa90406385de91c7e982a769ca9314d848f93bc0d3f2", size = 259933, upload-time = "2026-10-22T16:05:25.79Z" },
{ url = "https://files.pythonhosted.org/packages/40/b6/e17a19dccdbcad494c64691f71c1dba3e55c30625bc29ffbb88ac5a0aa8b/coverage-7.17.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:b609df3d6d900464207532c5170a65c6ede8d2e0b91f151f90008a866395cd76", size = 257972, upload-time = "2026-10-22T16:05:27.185Z" },
{ url = "https://files.pythonhosted.org/packages/6a/0d/37dac534709a3fa43a937c8b0604839bb44b8670b83d143a4b78fec20d1e/coverage-7.17.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a01ef92caeedfa4f2ad171a3a8e6fece4565de871d8bca428151bf9929f41048", size = 262271, upload-time = "2026-10-22T16:05:28.619Z" },
{ url = "https://files.pythonhosted.org/packages/cb/17/7c05186925b4d64a46c271dfd944e3f0c1ad89db9095c31a6df157a75df2/coverage-7.17.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a3914e2c98c34b94c26d2add3d2a61ccfd3534fc4eaa879ee290bdbb5184ba6c", size = 257604, upload-time = "2026-10-22T16:05:30.07Z" },
{ url = "https://files.pythonhosted.org/packages/95/65/c241cdee66a0ff86c68f6d37328e735d10e3d30cd6ff66f356039eb688d2/coverage-7.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8a72b26dc5c9a9422e45472b1d67ba5caf273c524c8668cc04b23062663755e", size = 259714, upload-time = "2026-10-22T16:05:31.402Z" },
{ url = "https://files.pythonhosted.org/packages/db/09/e93233fe2405574ce0a31ed201c0cba3a860e085db5d2828bf12b7dc0790/coverage-7.17.0-cp314-cp314-win32.whl", hash = "sha256:03baf0688e14a5a44ffd1eefa8928dfc92b14565e521174e072b31b971aa1657", size = 227914, upload-time = "2026-10-22T16:05:32.78Z" },
{ url = "https://files.pythonhosted.org/packages/d7/77/04979eb217edb94b94afabb4bcc179f60c746f68d3fd0b12688ba9195730/coverage-7.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:3a4459b0f389dd1e51aac3dbfc4ca35b30cae7dc99caa3c0a086d1997a985709", size = 228543, upload-time = "2026-10-22T16:05:34.118Z" },
{ url = "https://files.pythonhosted.org/packages/a0/38/155f2f2c6769f8c43eca8a5e581443faa0908828650090b97103b4a41376/coverage-7.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32ee5831b15ba239bef5d272ae586ca8acea4a661b5f2941f5a0d380e518b23", size = 228396, upload-time = "2026-10-22T16:05:35.951Z" },
{ url = "https://files.pythonhosted.org/packages/52/55/d6751572d9445485c4c5a51ec6ae11caa5c11b7ef2448cdccc884f7f54d9/coverage-7.17.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9c083e253e96fdeeaf48133fe836cac86ace2db215d235700e7f7532420a200c", size = 226486, upload-time = "2026-10-22T16:05:37.363Z" },
{ url = "https://files.pythonhosted.org/packages/2f/a8/82d37760e537bfdb9fdee285fa1e1b45bc153293d23e2331c7befedd1990/coverage-7.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b88fb4d4d7608cbcebdc7ff7dbffc2115e3b81d6c33a79147cd79d54fcb080f4", size = 226861, upload-time = "2026-10-22T16:05:38.777Z" },
{ url = "https://files.pythonhosted.org/packages/24/96/70ec00a9db6d7bdd3f914eba12057360769f5cb78a98456e71fe1937deb5/coverage-7.17.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2bf3ee38a40c0570c316b9e7507c472469586906a20f289705898528c31aaea8", size = 266849, upload-time = "2026-10-22T16:05:40.285Z" },
{ url = "https://files.pythonhosted.org/packages/77/d8/d99be3bfbb208512f039c80b31e404138a86678fc929a17acd5b7135a6ae/coverage-7.17.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7c570293f001ea91598b90e90cb9b98fddee833e90bbede432cbd218331ccd8e", size = 269402, upload-time = "2026-10-22T16:05:41.585Z" },
{ url = "https://files.pythonhosted.org/packages/82/50/6cbe8b5a5060edbd21ce8109c4e900f8b7656e8b72ede6dc4d82dc0b8bfb/coverage-7.17.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:791f9ee09814dd30d9562b4f4eb037a371485ec1c830b53999d2320d20c4e764", size = 272025, upload-time = "2026-10-22T16:05:42.921Z" },
{ url = "https://files.pythonhosted.org/packages/bd/05/d3633add01e5518dfdacfdbd1be41cb5c581412dd8bccd663edf6ef7fec8/coverage-7.17.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7c5672ecf1b497de7a0b37743bef40e3b58e3207e9fe2ed6682cb0420836a88", size = 273515, upload-time = "2026-10-22T16:05:44.324Z" },
{ url = "https://files.pythonhosted.org/packages/57/d9/b83387780271bf9ea9f96a3aeb01c1c302c4494a927923c4fa70cd170a3f/coverage-7.17.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:337f87e62e1c5f43c8198d879f677c342d152f84d3f157add12558853cbb8eaf", size = 266725, upload-time = "2026-10-22T16:05:45.713Z" },
{ url = "https://files.pythonhosted.org/packages/e4/be/8e80b29764ae3f4d70d44952eee3f3927eb36897490fe799e400adb7f5da/coverage-7.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c8a58baa8eb6154c6645eadcc19b6c089deab78c24a612a83206f2a8a5992a7c", size = 269312, upload-time = "2026-10-22T16:05:47.054Z" },
{ url = "https://files.pythonhosted.org/packages/b8/c9/e702f588fe8d83ca844f133270ad0a49bf650c8cf260551ff6f11a6b6bbf/coverage-7.17.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fb5745048ee20f796b0e3ab61831049e749acff188563e2af32f58ede957ac1b", size = 266740, upload-time = "2026-10-22T16:05:48.343Z" },
{ url = "https://files.pythonhosted.org/packages/8a/11/ee13ad14b714d75ef07c490b6e43969574a8b8b8c6d67bc741fcbacbf9c3/coverage-7.17.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:7b5abcae74750302ac4bc77351eaee87c450e4abd3b6225eacde3858e260f8b2", size = 271036, upload-time = "2026-10-22T16:05:49.727Z" },
{ url = "https://files.pythonhosted.org/packages/e2/bc/854e8912c11c54aaf416e74602000589c6efbbe1f251dc90202fdabddcb6/coverage-7.17.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4e153f0bd6509dbf837fd585f489b3328bfd98f2a718a0b15ae63032148b285c", size = 266158, upload-time = "2026-10-22T16:05:51.141Z" },
{ url = "https://files.pythonhosted.org/packages/a8/dd/96432830ddf63bc42654de54eee77a0cd6949cc65769bda06f353323f23a/coverage-7.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e42a835271922fe7bf98c7a05c78d4032f4e52bf5b07fafa1eadfdfc2f0ad22f", size = 268088, upload-time = "2026-10-22T16:05:52.92Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ee/230c6ec065a6629d41cc89323ceb54d37e2650c104f70fc108f4ee7c5051/coverage-7.17.0-cp314-cp314t-win32.whl", hash = "sha256:5900055351ec89fdffb34b27898815106529ded29a9496464c1ebd6313fd40fa", size = 228229, upload-time = "2026-10-22T16:05:54.255Z" },
{ url = "https://files.pythonhosted.org/packages/f0/1d/23eb6aadcb3c00764ff2961c0ff55f5dfa2543856d932b949bbc04fa68e9/coverage-7.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5ee9c8bd13a59e301dc2aefe7aa8384633406808de051b1982775667094eb673", size = 229154, upload-time = "2026-10-22T16:05:55.682Z" },
{ url = "https://files.pythonhosted.org/packages/3e/f1/7df117e8e016b7a3ee459e833b3456f3610505afefd071f8727702d98439/coverage-7.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a9cb9a0fb58e6fff5442969af36094ecd6976f4907f179e33b27e15355d8f74", size = 228726, upload-time = "2026-10-22T16:05:57.179Z" },
{ url = "https://files.pythonhosted.org/packages/c9/17/48032919343afa2906112d458aceda2043afdb24a2ee8eb4c60904d8ddf1/coverage-7.17.0-py3-none-any.whl", hash = "sha256:bac487dce0d2aff28ddf1a533a916f771416eee0e493960f611e49037b878e9d", size = 217383, upload-time = "2026-10-22T16:06:48.471Z" },
]
[[package]]
name = "django"
version = "5.2.18"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/85/fe/79e692b430c7721bf4b812c328bfe687e73e3f28f56611a6a18a97e887cc/django-5.2.18.tar.gz", hash = "sha256:461c5dd06d2ea16bd5ca37d3f46e4def1d6b0fe7588c6f4e2119517bb0af8b2d", size = 10910238, upload-time = "2026-10-06T13:01:09.371Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/01/6568ec52b26548ca1b83d33249f7274db3bf74698f42c0da42ec9db1a24a/django-5.2.18-py3-none-any.whl", hash = "sha256:92ed81d500be6408ecd704d7bd1366c534f30427bffcc63c5fefb129561aec7c", size = 8316071, upload-time = "2026-10-06T13:01:04.413Z" },
]
[[package]]
name = "django-extensions"
version = "4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/b3/ed0f54ed706ec0b54fd251cc0364a249c6cd6c6ec97f04dc34be5e929eac/django_extensions-4.1.tar.gz", hash = "sha256:7b70a4d28e9b840f44694e3f7feb54f55d495f8b3fa6c5c0e5e12bcb2aa3cdeb", size = 283078, upload-time = "2025-04-11T01:15:39.617Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" },
]
[[package]]
name = "django-ninja"
version = "1.7.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/8e/8c12dd873d76762e61fa7225bbab5d2b87dce358a8f7d764508b1363c17c/django_ninja-1.7.3.tar.gz", hash = "sha256:8a73ecccaa86639202427e81667b8fca6dfc250d2b6e5108369688029a4cb593", size = 2342134, upload-time = "2026-10-29T10:21:03.197Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/7d/7df99529f54178c67282d25983aad66bc0593ffbf5c1d420bb2840389c90/django_ninja-1.7.3-py3-none-any.whl", hash = "sha256:5e3c2dcead83a2512ea772dbc435ca51632cf46bf3be1523bac41e5bb394891c", size = 2376577, upload-time = "2026-10-29T10:21:01.199Z" },
]
[[package]]
name = "django-postfix-dovecot-api"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "django" },
{ name = "django-extensions" },
{ name = "django-ninja" },
{ name = "libpass" },
{ name = "sentry-sdk" },
{ name = "whitenoise" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "pytest-django" },
{ name = "ruff" },
]
prod = [
{ name = "gunicorn" },
{ name = "mysqlclient" },
]
[package.metadata]
requires-dist = [
{ name = "django", specifier = ">=5.2,<6" },
{ name = "django-extensions", specifier = ">=4.1" },
{ name = "django-ninja", specifier = ">=1.7.3" },
{ name = "libpass", specifier = ">=1.9.4" },
{ name = "sentry-sdk", specifier = ">=2.73.0" },
{ name = "whitenoise", specifier = ">=6.12.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=9.2.0" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
{ name = "pytest-django", specifier = ">=4.14.0" },
{ name = "ruff", specifier = ">=0.17.3" },
]
prod = [
{ name = "gunicorn", specifier = ">=26.2.0" },
{ name = "mysqlclient", specifier = ">=2.3.0" },
]
[[package]]
name = "gunicorn"
version = "26.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d9/8a/e4ef6ee11701b6cd64702848415ffb69eeff85cb388a3c6c7fe86f22f3f8/gunicorn-26.2.0.tar.gz", hash = "sha256:62b864895d9ebff0b2f9867ba04fe811c93121596540830c9c916d0769668447", size = 787921, upload-time = "2026-08-24T15:05:59.3Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl", hash = "sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3", size = 228389, upload-time = "2026-08-24T15:05:57.67Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
]
[[package]]
name = "libpass"
version = "1.9.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/2d/953db970fa7bccd1d74e2870158878ab5b46776f3f34180ca49ce93c4ac8/libpass-1.9.4.tar.gz", hash = "sha256:4ff9a5ebf51aad1e8164453bc29c4a06926cf175a9501d87205612ba247f3cbd", size = 704877, upload-time = "2026-10-17T07:49:01.714Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/35/d08a8175f20cb453b3133845afce81378437730f5cd402bb411a5fd54841/libpass-1.9.4-py3-none-any.whl", hash = "sha256:a608621af2d7d64579957988fac287f230f88792c83a2d0f895afb05ee429489", size = 307138, upload-time = "2026-10-17T07:49:00.39Z" },
]
[[package]]
name = "mysqlclient"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ef/8f/b9488795d21a76c1520905feba5afb6233f16510797a28b51f2b2688c93e/mysqlclient-2.3.0.tar.gz", hash = "sha256:bea8294964266f6486f1ca514ccfcdbc54d4fe0d32882b38c1d4594df870be8b", size = 102185, upload-time = "2026-09-14T15:38:30.784Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/5f/f62d1263a942e97ca34ea8bf151c13a638fea7a5a544aa9db3d6dad7671d/mysqlclient-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:5d4c53eb9c5625dd68b6fed32c8cf00ba19cd1c3073645dec309a9d16bd029e2", size = 222543, upload-time = "2026-09-14T15:38:22.036Z" },
{ url = "https://files.pythonhosted.org/packages/9d/e5/1de3e1fc27009a6da30a52167e98f52ab4277d089eaff7152f6156745af9/mysqlclient-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:cfe14103280d5a4968fe8a3ace2a8939ef68a1d881aec9872d06746106b49f7f", size = 213120, upload-time = "2026-09-14T15:38:23.063Z" },
{ url = "https://files.pythonhosted.org/packages/b3/ea/d7ebc53af9d3341e5c049795ec5e88946d83cbd26ee468475ae0c3b21d7f/mysqlclient-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:673891700dafbc66a6a8df12059b53a65905ec6e8dec615392fb984299880c0d", size = 222969, upload-time = "2026-09-14T15:38:24.168Z" },
{ url = "https://files.pythonhosted.org/packages/4b/42/9ff798c066e33df9f7b58822a3eaf26185c3cb7e61a8186a56243b006066/mysqlclient-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:fe27c63ba9088b28467bf276f93f0b4a9062beabaeeb9692593e774d1146cce2", size = 213680, upload-time = "2026-09-14T15:38:25.259Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pluggy"
version = "1.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
]
[[package]]
name = "pydantic"
version = "2.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/d5/b4867fa7c7f04c34997d7d8aad744f64d210bb99d67cf18bf3fb1eea1aed/pydantic-2.14.2.tar.gz", hash = "sha256:fb7b2db6cefe8414dd612edeede49c4c433acb0432b0f92b95024e45f66c505d", size = 911596, upload-time = "2026-10-29T16:37:12.476Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/01/c71a71258b58659c72504e5fd65b451f6e36e34dc931d0b237ded213c814/pydantic-2.14.2-py3-none-any.whl", hash = "sha256:7efbf7507b937931cd32a5755acb00ea1cf5ccb1d4e8067a5850d5069e382118", size = 490403, upload-time = "2026-10-29T16:37:10.181Z" },
]
[[package]]
name = "pydantic-core"
version = "2.50.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/8e/23ec1021834e35041fbb2f10946141e6acdfd39379c45cc4d65a4e8a1d98/pydantic_core-2.50.2.tar.gz", hash = "sha256:1b0e8dc3932cf4e9ed5727570325cacc716b9409ffc794a221c0ccc72e8eb775", size = 518711, upload-time = "2026-10-29T16:34:57.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/64/fc525bb981a9f287cfa34346e7ed1d5ef7c043c2b9b517f54c6136032d10/pydantic_core-2.50.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:efad0bdc67d3b119c3bab6b94d341a27333c2bb89ed75dfa182a4a06cd0ea329", size = 2226414, upload-time = "2026-10-29T16:32:58.559Z" },
{ url = "https://files.pythonhosted.org/packages/db/68/315701095f25687938ab79d263a6d939337456c2840fe69038fd38000338/pydantic_core-2.50.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ffb4dce3ecb2cd563aa30f9eb5088a1cf1f4f688b90cbb374fa96e49fc50b02", size = 2095041, upload-time = "2026-10-29T16:32:59.873Z" },
{ url = "https://files.pythonhosted.org/packages/b8/4b/efd3abf31aa24bc97b311735f418886f947b4f193df02a1977643963b03e/pydantic_core-2.50.2-cp314-cp314-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:150f6e101f575c21a4ecfed1be1c05bb89a0ad49cb9c846ed377583bd71b32c2", size = 2257635, upload-time = "2026-10-29T16:33:01.096Z" },
{ url = "https://files.pythonhosted.org/packages/2d/6c/146b36eed4de8115a0fda76840703e75f5f8b36aeb3af86976bf094d1a17/pydantic_core-2.50.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1db9cab6197e797824f0479dd2ae5fd2e272aa61c61b7faa5a6e450ca7a0d3b7", size = 2116807, upload-time = "2026-10-29T16:33:02.692Z" },
{ url = "https://files.pythonhosted.org/packages/fb/94/db006ea79bbe405cc38b25899319c7d461ff1813cbdfe6aa90a65ed1dfff/pydantic_core-2.50.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:1b9f652e17a13bbc09b45f74c3c326cca72059ce4dcdef687fcb04389db413a9", size = 2201037, upload-time = "2026-10-29T16:33:03.942Z" },
{ url = "https://files.pythonhosted.org/packages/41/f9/ea2bc1ec13a67af4a63c9a887fa0aa6ef4ade9018e2bb412d4fc80110e19/pydantic_core-2.50.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:823b222c4fc861a82c650998a6117ecbd150cc71bf8b78f9a31bf3f48c456108", size = 2374904, upload-time = "2026-10-29T16:33:05.458Z" },
{ url = "https://files.pythonhosted.org/packages/44/8e/26a6a075301f0e563646037ea69bef8c63911d38fbd257f94cec69cdf985/pydantic_core-2.50.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:783abdfac21247d33bec40aa683d26bba30d61655c40de38b2decc4797aabf92", size = 2457626, upload-time = "2026-10-29T16:33:07.271Z" },
{ url = "https://files.pythonhosted.org/packages/9d/a2/8eeabaf1d394f0d3b9a27129ac21556eabee6f2ddcb418069ef4037e00b0/pydantic_core-2.50.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b29b7c5adf4b2be7c941f7f76432aa3269cd5052ff8610376b8ece24d7b02c51", size = 2245505, upload-time = "2026-10-29T16:33:08.824Z" },
{ url = "https://files.pythonhosted.org/packages/bd/54/449778cd397e2682b30d1bcfab0872f6ac36702b59cc326296b7f4f14928/pydantic_core-2.50.2-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:4ac996e42a93d80122a23edce44e02c6b9c4913f5c6198a077da31475341a39a", size = 2259563, upload-time = "2026-10-29T16:33:10.107Z" },
{ url = "https://files.pythonhosted.org/packages/26/c2/2f65691d25488ee0f3c17861d079e8ccb7817955522d07cd3c3970d30af5/pydantic_core-2.50.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:99e1d482138a71902415ba375bb4627679a5fefd3bd07803c3a8f37632f4981d", size = 2290470, upload-time = "2026-10-29T16:33:11.273Z" },
{ url = "https://files.pythonhosted.org/packages/9f/32/aa7c40234cd24536afa0895ff1d26f9f15638004e25242c5193ce053b96b/pydantic_core-2.50.2-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:a53d5f11cd6d15569ba3e53f3498077f0d5e633f6ec604d2ea613e895017ebee", size = 2477272, upload-time = "2026-10-29T16:33:12.771Z" },
{ url = "https://files.pythonhosted.org/packages/c4/43/c347c68e040cf93d8487a8f4fc28f2768312834c7d21c301538f416f9955/pydantic_core-2.50.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23385c4626f8b5a7792aa381ea573d9432cfbf4c21375a0a5eed2a4fb64e0e58", size = 2484352, upload-time = "2026-10-29T16:33:14.485Z" },
{ url = "https://files.pythonhosted.org/packages/b8/8e/72556126c8b6154fe7acaf757b96b2daa37aeffc3a517deb76e272f5ad09/pydantic_core-2.50.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9a8a62281fd9c6a373b3fd3d473685e6838d43341eecde5f47729748e2c11ee3", size = 1491226, upload-time = "2026-10-29T16:33:15.603Z" },
{ url = "https://files.pythonhosted.org/packages/5b/66/004c85511812964a36b3535af022c5c0dfcb760a31e4905b96310e8dd6c8/pydantic_core-2.50.2-cp314-cp314-win32.whl", hash = "sha256:18f008ef58cd30782f81b6e0119e20b7e884edcefbc2d370162ebe033f6e5c99", size = 2113134, upload-time = "2026-10-29T16:33:16.87Z" },
{ url = "https://files.pythonhosted.org/packages/a3/5f/dcd3df20ab2a7279d42a8415fc184e8e77bcf9aa3c9ac3e938c1300a242c/pydantic_core-2.50.2-cp314-cp314-win_amd64.whl", hash = "sha256:b62c89545d21d930c333208fd548e7b4f262228ba3a322fd75c987a75ab11f31", size = 2253407, upload-time = "2026-10-29T16:33:18.47Z" },
{ url = "https://files.pythonhosted.org/packages/4e/fb/0685895f5d199b215011dc4eb8ccea032a64ec23dfa88eeb5acca939c2d2/pydantic_core-2.50.2-cp314-cp314-win_arm64.whl", hash = "sha256:6e731bf5a5e8e25398544d7709e39068ec4230b0177e4db7fa955692fd415ba3", size = 2160456, upload-time = "2026-10-29T16:33:19.782Z" },
{ url = "https://files.pythonhosted.org/packages/84/2f/4c8f67efe62837a508b74cf910904f73029cb211371351c5ceb36d56e85e/pydantic_core-2.50.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:33c6800ab9563adaa0d7893e9cd8f5b972ef1e6a23df70498e05c24f3fcec5e1", size = 2217558, upload-time = "2026-10-29T16:33:21.323Z" },
{ url = "https://files.pythonhosted.org/packages/ce/73/e0ef88cfbcca5409f57751289bc20be63a58af596a36e94b8cd795e59747/pydantic_core-2.50.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f81df206b937d0dca5efb38c25fc99037d82dbcd3740e34c3f81997d2322ac1c", size = 2074335, upload-time = "2026-10-29T16:33:22.658Z" },
{ url = "https://files.pythonhosted.org/packages/7b/cc/47844a3179ebce27064e13619594b3fc1a9b4509d8dec1e3960c5f87f5bb/pydantic_core-2.50.2-cp314-cp314t-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:21ea3b00f1e87c66f3f9e92f9f6fec20495ac0e60d7d2e68ba1fc49739d7cf40", size = 2264201, upload-time = "2026-10-29T16:33:24.953Z" },
{ url = "https://files.pythonhosted.org/packages/bc/d1/30e9d3c86d81884ee3199560ddfa2c1a66a8dc0736441748eae43064abbc/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9eff570402175c7d5fff8497d5a9c05580664a79bb66c07b8f205d17f69e580b", size = 2094353, upload-time = "2026-10-29T16:33:26.739Z" },
{ url = "https://files.pythonhosted.org/packages/d4/8f/392a5999480d0bcab4539db0dafa93265152396fd63e87be4d24e13d6d82/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:240fec32a74af0979f7253532859b3fd209ad5eea822416bb010929dba445c3d", size = 2203119, upload-time = "2026-10-29T16:33:28.154Z" },
{ url = "https://files.pythonhosted.org/packages/cb/80/6e4e276a3fde5500224a39150e06a4f134f136165908be69402720718a36/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92acf02c770b9d5c4e9a3e8d64e9d95ae26456a86ebb7b7cf64a97f899e5871b", size = 2392881, upload-time = "2026-10-29T16:33:29.305Z" },
{ url = "https://files.pythonhosted.org/packages/70/d5/5eb56c6317ab4abf33ef9519a50bfac0c96f7f408bad1bc36da6f27d43e4/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:1ac5b99bc7b0a68ec64f5f75a6828a7bab5535c8df0cdc07901deb50e0815910", size = 2457290, upload-time = "2026-10-29T16:33:30.587Z" },
{ url = "https://files.pythonhosted.org/packages/7b/9e/f1fe9384bb37e4e728c51c79151808deacf7b2502e245f50b57c85eeb199/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:713d7c33c0e231e3786eb0ec95885814dd65d5f3a09754399b6acbf5e9365f11", size = 2241568, upload-time = "2026-10-29T16:33:32.04Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f1/cffc4f8456c35690bcb65a8f2304afbf2f519766345a28aba553d5a55cc8/pydantic_core-2.50.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:61e13100b30d8d25fc0d1f0297014b32941bf0975a3584bd08f3adebb68d1ead", size = 2257185, upload-time = "2026-10-29T16:33:33.36Z" },
{ url = "https://files.pythonhosted.org/packages/84/34/ccd4df1cc39cb1e42fb7ec86a0dc0a23e8479de04d3012ca6b0bf2279a03/pydantic_core-2.50.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:523fe6574aea50f456cbc301c1cdddcbf237939f7657f9af99b4f0f71942b59c", size = 2299384, upload-time = "2026-10-29T16:33:34.799Z" },
{ url = "https://files.pythonhosted.org/packages/83/60/404b4b0c416a61867ee642cdc1450bca4d8a9a10e5f5f21e1ecaad05fdc8/pydantic_core-2.50.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:6c25b8826be85714b27dafe908d3461e1b09e3e31c0279a7029040c832c781ef", size = 2479384, upload-time = "2026-10-29T16:33:36.106Z" },
{ url = "https://files.pythonhosted.org/packages/ca/0d/57c81418a2fdfde0578c71396df773965adebd4421d9985a0f8dcbe9ec4d/pydantic_core-2.50.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a59db03b80a156a6fbeae41096866d1cb89cdf31031ba44c768a804538880636", size = 2504616, upload-time = "2026-10-29T16:33:37.423Z" },
{ url = "https://files.pythonhosted.org/packages/08/66/dec78e28a604f3f4985f5a795d4da3e5fef78b0921aeb7f614ed7c780a32/pydantic_core-2.50.2-cp314-cp314t-win32.whl", hash = "sha256:0d090ae76d5b09c003600c7779b3adcf3d1b49268f616c5c51d844e54c8e318d", size = 2093179, upload-time = "2026-10-29T16:33:38.705Z" },
{ url = "https://files.pythonhosted.org/packages/d2/ea/79d9ee60648463804145d44b08f1e7e2474427d9704e3331d814483908b3/pydantic_core-2.50.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9b01a092955c1c7530196ad526755cff4f2d11f37ca9a6fc9ebe37ca49a48977", size = 2220575, upload-time = "2026-10-29T16:33:39.929Z" },
{ url = "https://files.pythonhosted.org/packages/eb/01/aa6a7bd017c3228a41942da4fd788b86fb4aad9677212a4737a0c157fe74/pydantic_core-2.50.2-cp314-cp314t-win_arm64.whl", hash = "sha256:4ea01f30e119f1ec7a2034e85c40f491d469896aef02cd6436fd9292fe1be3fb", size = 2160866, upload-time = "2026-10-29T16:33:41.237Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" },
]
[[package]]
name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "pytest-django"
version = "4.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/f6/3851312120c2bf2f19cafff931e75059aad1ba670703cd751e2fde9bc942/pytest_django-4.14.0.tar.gz", hash = "sha256:26787dd3f422cfbab8f55b80a776e2edea7a11092cb74e960bef1312515708ef", size = 94700, upload-time = "2026-08-10T14:13:08.319Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/03/850bffad2b581c440ca51c039d74504d5a422c94bda0bdb8a8ba5068d48b/pytest_django-4.14.0-py3-none-any.whl", hash = "sha256:c533b08d89cc675efcd5398eea270b34547e35f9a3608e2c9748dd88428ea187", size = 27067, upload-time = "2026-08-10T14:13:06.998Z" },
]
[[package]]
name = "ruff"
version = "0.17.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" },
{ url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" },
{ url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" },
{ url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" },
{ url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" },
{ url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" },
{ url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" },
{ url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" },
{ url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" },
{ url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" },
{ url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" },
{ url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" },
{ url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" },
{ url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" },
]
[[package]]
name = "sentry-sdk"
version = "2.73.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c5/3b/1e47acf2456781c26e12007379ecc154bf310b8f64d40390d78b791d55c3/sentry_sdk-2.73.0.tar.gz", hash = "sha256:4a2ab97b16e4693c325ab361c7bbbb346dbc7d0bc0150922d795a25bac8d5280", size = 1079078, upload-time = "2026-10-22T09:08:49.63Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/88/cabf98d017d7b940720e514e5f8ee02e3e6f7495caf93c30735a38387ec4/sentry_sdk-2.73.0-py3-none-any.whl", hash = "sha256:0bcfa6928ea5adbb89591858b514d27adac27c7a3cccc31b20ed10e0b09eda9e", size = 549011, upload-time = "2026-10-22T09:08:47.88Z" },
]
[[package]]
name = "sqlparse"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/e0/361c764ea58cc1a75f7b4b424a74b08449db8bfa5e9199c7275999fdd1b9/sqlparse-0.6.1.tar.gz", hash = "sha256:ca722573f848ff2d30e40ea30f53a104b7dea7635ea9285891263eb2c58fb6e7", size = 182236, upload-time = "2026-11-01T18:06:45.951Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/ff/2fee788e82e75be8f210c65c5d103bf44c880e3769c884652a4cd911b8ba/sqlparse-0.6.1-py3-none-any.whl", hash = "sha256:3be5a72d04c873d690d7c6dedd33d6fae2ef82759881ceec02fc2efd0cba564f", size = 51200, upload-time = "2026-11-01T18:06:47.434Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "tzdata"
version = "2026.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7a/29/6a1391d31cd00ff9e927215a224a880e1e84e826b64ba80a499e51c074d8/tzdata-2026.6.tar.gz", hash = "sha256:85d2cd9c09f81f0bb29db27759fc83af4f1e620efb814f8c5bf8b4cc4feece6d", size = 201017, upload-time = "2026-10-28T09:57:09.137Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/66/9a06c2227fedfd4919f0eaaa8b94377d9730882a7e4f95823c9a331ab5e1/tzdata-2026.6-py2.py3-none-any.whl", hash = "sha256:0a10eec6e13ef16b627fabed9a5f2ba61a62b1c67f327305f4e7e412a70b4aec", size = 348210, upload-time = "2026-10-28T09:57:07.276Z" },
]
[[package]]
name = "urllib3"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
]
[[package]]
name = "whitenoise"
version = "6.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" },
]