35 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
Nikolai R Kristiansen 3b9a43fc7a 🐛Allow longer password hashes 2020-09-09 23:42:58 +02:00
nikolark 1104c64c2a Correct Sentry DSN 2019-12-31 00:27:10 +01:00
nikolark 946af247c6 ⬆ Bump all the things 2019-12-31 00:21:23 +01:00
Nikolai R Kristiansen 35929d755a ⬆️ bump deps 2018-08-12 22:00:38 +02:00
ponas e3567ed06d Add django MessageMiddleware 2018-04-04 19:51:08 +02:00
Nikolai R Kristiansen 861f890567 Settings fixes
`MIDDLEWARE_CLASSES` is no more
2018-03-17 13:36:28 +01:00
Nikolai R Kristiansen e3fcb4b543 Remove deprecated middleware, add sentry 2018-03-17 13:30:29 +01:00
Nikolai R Kristiansen 8c0c84eb49 ⬆️Django 2.x 2018-03-17 13:09:43 +01:00
Nikolai R Kristiansen 70be41df96 ⬆️ Bump deps 2018-03-17 11:42:19 +01:00
nikolark 3daa708ea2 Update .gitlab-ci.yml 2017-11-20 14:42:52 +01:00
Nikolai R Kristiansen 7ad5191361 chore: run ci 2017-11-20 14:33:14 +01:00
Nikolai R Kristiansen 39f4ee654a Fix delete bulk
Add test
2017-11-20 14:28:42 +01:00
nikolark 053c02d8bc Merge branch 'bump' into 'master'
Bump

See merge request edb/django-postfix-dovecot-api!2
2017-11-01 23:36:21 +01:00
Nikolai R Kristiansen f0a7858811 Fix regex lookup
Note: Renames filter fields:
 - source_regex to source__iregex
 - destination_regex to destination__iregex
2017-11-01 23:29:57 +01:00
Nikolai R Kristiansen 2a037fbe8f Try fixing regex lookup
Adds tests
2017-11-01 21:44:15 +01:00
Nikolai R Kristiansen 4e41af0bd8 Bump to latest django, drf
TODO: Fix regex filter (write a test)
2017-10-25 21:36:20 +02:00
nikolark ad025f17d8 Add gunicorn dep, deploy with supervisor 2016-02-08 23:33:53 +01:00
nikolark 2a2baef6d2 Merge branch 'python3' into 'master'
python3 compat and deps++



See merge request !1
2016-02-08 23:15:16 +01:00
nikolark 5efea835d0 python3 compat and deps++ 2016-02-08 23:07:01 +01:00
nikolark 88eeabe746 Allow filtering aliases on destination 2015-03-28 18:10:45 +01:00
nikolark c5654d7993 Adds comment 2015-03-24 17:31:44 +01:00
nikolark 243f480ce2 Allow duplicate emails in API, but dont save them 2015-03-24 17:30:56 +01:00
37 changed files with 1834 additions and 363 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 -1
View File
@@ -1,6 +1,10 @@
*.swp *.swp
*.pyc *.pyc
.idea .idea
venv
local_settings.py local_settings.py
db.sqlite3 db.sqlite3
venv
.venv
.coverage
.pytest_cache
/static
+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"]
+17 -17
View File
@@ -1,23 +1,23 @@
# Install # django-postfix-dovecot-api
apt install python-dev libmysqlclient-dev python-virtualenv
virtualenv venv
. venv/bin/activate
pip install -r requirements
# Deploy REST API for managing the virtual domains, users and aliases that Postfix and Dovecot read from the database.
First you need to have your public key in /home/gitdeploy/.ssh/authorized_keys on the server. Used by [mailadmin](https://git.neuf.no/edb/mailadmin).
Then, to deploy via login.neuf.no, add the following to your personal ~/.ssh/config ## Development
Host lynx.neuf.no ```sh
User gitdeploy mise install
ForwardAgent yes uv sync
ProxyCommand ssh -W %h:%p login.neuf.no -l USERNAME prek install
uv run python manage.py migrate
uv run python manage.py runserver
uv run pytest
```
Then you can do API docs are served at `/api/docs`. Override settings (database, secret key, Sentry) in
`dpdapisite/local_settings.py`.
fab deploy ## Deployment
# Inspiration: CI builds an image for each push to `main` and deploys it to lynx while the repository variable `DEPLOY_LYNX` is
- http://modoboa.readthedocs.org/ `true`. The image reads its settings from the environment (`dpdapisite/settings_prod.py`).
- https://github.com/lgunsch/django-vmail
+4 -4
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)
@@ -17,4 +17,4 @@ class DomainAdmin(admin.ModelAdmin):
@admin.register(User) @admin.register(User)
class UserAdmin(admin.ModelAdmin): class UserAdmin(admin.ModelAdmin):
pass pass
+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)
-17
View File
@@ -1,17 +0,0 @@
import django_filters
from dpdapi.models import Alias
from rest_framework.filters import BaseFilterBackend
class AliasRegexFilter(django_filters.FilterSet):
# Ref: https://docs.djangoproject.com/en/1.7/ref/models/querysets/#iregex
source_regex = django_filters.CharFilter(name="source", lookup_type='iregex')
class Meta:
model = Alias
class AliasRegexFilterBackend(BaseFilterBackend):
def filter_queryset(self, request, queryset, view):
return AliasRegexFilter(request.query_params, queryset=queryset).qs
+36 -36
View File
@@ -1,60 +1,60 @@
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
import os
from dpdapi.models import Alias, Domain 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("{} does not exist".format(args[0]))
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('Created domain {}.'.format(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('Could not import \'{}={}\', already exists.'.format(a.source, a.destination)) 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)))
+2 -2
View File
@@ -46,7 +46,7 @@ class Migration(migrations.Migration):
('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')), ('domain', models.ForeignKey(to='dpdapi.Domain', on_delete=models.CASCADE)),
], ],
options={ options={
'abstract': False, 'abstract': False,
@@ -56,7 +56,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='alias', model_name='alias',
name='domain', name='domain',
field=models.ForeignKey(to='dpdapi.Domain'), field=models.ForeignKey(to='dpdapi.Domain', on_delete=models.CASCADE),
preserve_default=True, preserve_default=True,
), ),
] ]
@@ -0,0 +1,18 @@
# Generated by Django 2.2.16 on 2020-09-09 21:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dpdapi', '0002_auto_20150118_1638'),
]
operations = [
migrations.AlterField(
model_name='user',
name='password',
field=models.CharField(max_length=512),
),
]
+14 -16
View File
@@ -1,4 +1,3 @@
from __future__ import unicode_literals
from django.db import models from django.db import models
@@ -13,45 +12,44 @@ class BaseModel(models.Model):
class Alias(BaseModel): class Alias(BaseModel):
source = models.EmailField(max_length=254) source = models.EmailField(max_length=254)
destination = models.EmailField(max_length=254) destination = models.EmailField(max_length=254)
domain = models.ForeignKey('dpdapi.Domain') domain = models.ForeignKey("dpdapi.Domain", models.CASCADE)
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
def __unicode__(self): class Meta:
return "{}={}".format(self.source, self.destination) verbose_name_plural = "aliases"
unique_together = ("source", "destination", "domain")
def __str__(self):
return f"{self.source}={self.destination}"
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.source = self.source.lower() self.source = self.source.lower()
self.destination = self.destination.lower() self.destination = self.destination.lower()
super(Alias, self).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)
def __unicode__(self): def __str__(self):
return self.name return self.name
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.name = self.name.lower() self.name = self.name.lower()
super(Domain, self).save(*args, **kwargs) super().save(*args, **kwargs)
class User(BaseModel): class User(BaseModel):
email = models.EmailField(max_length=254, unique=True) email = models.EmailField(max_length=254, unique=True)
password = models.CharField(max_length=109) password = models.CharField(max_length=512)
domain = models.ForeignKey('dpdapi.Domain') domain = models.ForeignKey("dpdapi.Domain", models.CASCADE)
def __unicode__(self): def __str__(self):
return self.email return self.email
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.email = self.email.lower() self.email = self.email.lower()
super(User, self).save(*args, **kwargs) super().save(*args, **kwargs)
-37
View File
@@ -1,37 +0,0 @@
from rest_framework import serializers
from passlib.hash import sha512_crypt
from .models import Alias, Domain, User
class AliasSerializer(serializers.ModelSerializer):
class Meta:
model = Alias
class AliasDeleteSerializer(serializers.ModelSerializer):
class Meta:
model = Alias
validators = [] # No Unique togheter check on deletion
class DomainSerializer(serializers.ModelSerializer):
class Meta:
model = Domain
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
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
-3
View File
@@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.
-10
View File
@@ -1,10 +0,0 @@
from rest_framework.routers import DefaultRouter
from .views import AliasViewSet, DomainViewSet, UserViewSet
router = DefaultRouter()
router.register(r'aliases', AliasViewSet)
router.register(r'domains', DomainViewSet)
router.register(r'users', UserViewSet)
urlpatterns = router.urls
-73
View File
@@ -1,73 +0,0 @@
from __future__ import unicode_literals
import logging
import operator
from django.db.models import Q
from rest_framework.decorators import list_route
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status, filters, viewsets
from dpdapi.filters import AliasRegexFilterBackend
from dpdapi.models import Alias, Domain, User
from dpdapi.serializers import AliasSerializer, AliasDeleteSerializer, DomainSerializer, UserSerializer
logger = logging.getLogger(__name__)
class AliasViewSet(viewsets.ModelViewSet):
""" Aliases """
queryset = Alias.objects.all()
serializer_class = AliasSerializer
permission_classes = [IsAuthenticated]
filter_backends = (filters.DjangoFilterBackend, AliasRegexFilterBackend)
filter_fields = ('domain', 'domain__name',)
@list_route(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)
@list_route(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 = (filters.DjangoFilterBackend,)
filter_fields = ('name',)
class UserViewSet(viewsets.ModelViewSet):
""" Users """
queryset = User.objects.all()
serializer_class = UserSerializer
permission_classes = [IsAuthenticated]
+88 -59
View File
@@ -1,98 +1,127 @@
""" """
For more information on this file, see For more information on this file, see
https://docs.djangoproject.com/en/1.7/topics/settings/ https://docs.djangoproject.com/en/stable/topics/settings/
For the full list of settings and their values, see For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.7/ref/settings/ https://docs.djangoproject.com/en/stable/ref/settings/
""" """
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) from contextlib import suppress
import os from pathlib import Path
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/stable/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'hlaf#yma(8s!cy-bf5e6kc&_5n_l1h!34c(j32-b3v8o7pg$5n' 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! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
TEMPLATE_DEBUG = True
ALLOWED_HOSTS = [] ALLOWED_HOSTS = []
# Application definition # Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.messages",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.staticfiles",
"django_extensions",
"ninja",
]
INSTALLED_APPS = ( INSTALLED_APPS += [
'django.contrib.admin', "dpdapi",
'django.contrib.auth', ]
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
)
INSTALLED_APPS += ( MIDDLEWARE = [
'rest_framework', "django.middleware.security.SecurityMiddleware",
'dpdapi', "whitenoise.middleware.WhiteNoiseMiddleware",
) "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
]
MIDDLEWARE_CLASSES = ( ROOT_URLCONF = "dpdapisite.urls"
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
)
ROOT_URLCONF = 'dpdapisite.urls' TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [
# insert your TEMPLATE_DIRS here
],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = 'dpdapisite.wsgi.application' WSGI_APPLICATION = "dpdapisite.wsgi.application"
# Database # Database
# https://docs.djangoproject.com/en/1.7/ref/settings/#databases # 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 # Internationalization
# https://docs.djangoproject.com/en/1.7/topics/i18n/ # 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) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.7/howto/static-files/ # https://docs.djangoproject.com/en/stable/howto/static-files/
STATIC_URL = "/static/"
STATIC_URL = '/static/' # Postfix and Dovecot read these tables directly; keep the existing integer id columns.
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
REST_FRAMEWORK = { STATIC_ROOT = BASE_DIR / "static"
'DEFAULT_AUTHENTICATION_CLASSES': ( STORAGES = {
'rest_framework.authentication.BasicAuthentication', "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
'rest_framework.authentication.SessionAuthentication', "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
)
} }
try:
from local_settings import * LOGGING = {
except ImportError: "version": 1,
pass "disable_existing_loggers": False,
"root": {
"level": "INFO",
"handlers": ["console"],
},
"formatters": {
"verbose": {"format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s"},
},
"handlers": {"console": {"level": "DEBUG", "class": "logging.StreamHandler", "formatter": "verbose"}},
"loggers": {
"django.db.backends": {
"level": "ERROR",
"handlers": ["console"],
"propagate": False,
},
},
}
with suppress(ImportError):
from .local_settings import * # noqa: F403
+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,
)
+8 -8
View File
@@ -1,11 +1,11 @@
from django.conf.urls import patterns, include, url
from django.contrib import admin from django.contrib import admin
from django.urls import path
from django.views.generic import RedirectView from django.views.generic import RedirectView
urlpatterns = patterns( from dpdapi.api import api
'',
url(r'^$', RedirectView.as_view(pattern_name='api-root')), urlpatterns = [
url(r'^api/', include('dpdapi.urls')), path("", RedirectView.as_view(pattern_name="api:openapi-view")),
url(r'^admin/', include(admin.site.urls)), path("api/", api.urls),
url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')) path("admin/", admin.site.urls),
) ]
+3 -10
View File
@@ -1,14 +1,7 @@
"""
WSGI config for pfrsite project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/1.7/howto/deployment/wsgi/
"""
import os import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dpdapisite.settings")
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dpdapisite.settings")
application = get_wsgi_application() application = get_wsgi_application()
Vendored
-63
View File
@@ -1,63 +0,0 @@
import logging
import sys
from contextlib import contextmanager as _contextmanager
from fabric.api import run, sudo, env, cd, prefix, lcd
from fabric.api import local as lrun
env.use_ssh_config = True
env.hosts = ['lynx.neuf.no']
env.project_path = '/var/www/neuf.no/django-postfix-dovecot-api'
env.user = 'gitdeploy'
env.activate = 'source {}/venv/bin/activate'.format(env.project_path)
@_contextmanager
def virtualenv():
with cd(env.project_path):
with prefix(env.activate):
yield
def __git_validate_clean():
with lcd(env.project_path):
if lrun('git diff-files', capture=True) != '':
logging.error("Your git-tree isn't clean! Commit your changes before deploying.")
sys.exit(1)
def test():
lrun("python manage.py test dpdapi")
def commit():
lrun("git add -p && git commit")
def push():
lrun("git push")
def prepare_deploy():
test()
commit()
push()
def gitstatus():
# __git_validate_clean()
with virtualenv():
# Get source
run('git branch -v')
def deploy():
# __git_validate_clean()
with virtualenv():
run('git pull') # Get source
run('pip install -r requirements.txt') # install deps in virtualenv
run('umask 022; python manage.py collectstatic --noinput') # Collect static
run('python manage.py migrate') # Run DB migrations
# Reload gunicorn
sudo('/etc/init.d/gunicorn reload', shell=False)
+7
View File
@@ -0,0 +1,7 @@
[tools]
python = "3.14"
uv = "latest"
prek = "latest"
[env]
_.python.venv = { path = ".venv", create = false }
+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
+59
View File
@@ -0,0 +1,59 @@
[project]
name = "django-postfix-dovecot-api"
version = "0.1.0"
description = "REST API for managing Postfix/Dovecot virtual domains, users and aliases"
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",
]
[dependency-groups]
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",
]
[tool.uv]
package = false
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "dpdapisite.settings"
testpaths = ["tests"]
pythonpath = ["."]
filterwarnings = ["error"]
addopts = "--cov --cov-report=term-missing"
[tool.coverage.run]
source = ["dpdapi", "dpdapisite"]
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"]
-7
View File
@@ -1,7 +0,0 @@
Django>=1.7,<1.8
django-extensions==1.4.9
django-filter==0.9.1
djangorestframework>=3.0,<3.1
passlib==1.6.2
mysqlclient==1.3.4
Fabric==1.10.1
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" },
]