13 Commits
Author SHA1 Message Date
ponas 7efa230deb feat: build an image and deploy it to lynx from ci
CI / tests (push) Successful in 1m52s
CI / prek (push) Successful in 17s
CI / image (push) Successful in 1m41s
CI / deploy-lynx (push) Successful in 27s
2026-11-03 04:13:05 +01:00
ponas 8f58d7fcf9 docs: add AGENTS.md and rewrite readme 2026-11-03 03:52:52 +01:00
ponas 7cd4b5dfc6 chore: remove fabric deploy script 2026-11-03 03:52:52 +01:00
ponas a21b4c9417 ci: replace gitlab ci with gitea workflow running pytest and prek 2026-11-03 03:52:46 +01:00
ponas 5474ad7bdc chore: add prek hooks with ruff 2026-11-03 03:52:46 +01:00
ponas 9350064c63 feat: serve static files with whitenoise 2026-11-03 03:52:46 +01:00
ponas 712f370245 refactor!: replace django rest framework with django-ninja 2026-11-03 03:52:46 +01:00
ponas cad499c19a test: cover api behaviour with pytest at 100% 2026-11-03 03:52:41 +01:00
ponas 08ffd39674 fix: accept filename argument in import_valiases 2026-11-03 03:52:41 +01:00
ponas 5ef366f667 fix: only delete exact alias matches in delete_bulk 2026-11-03 03:52:41 +01:00
ponas 83eb8f07f2 fix: hash user passwords on update 2026-11-03 03:52:41 +01:00
ponas cd8de0847c chore: upgrade to django 5.2 lts 2026-11-03 03:52:20 +01:00
ponas dd634d3717 build: manage python 3.14, uv and prek with mise 2026-11-03 03:52:19 +01:00
36 changed files with 1785 additions and 414 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
-19
View File
@@ -1,19 +0,0 @@
image: python:latest
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/pip-cache"
cache:
paths:
- "$CI_PROJECT_DIR/pip-cache"
key: "$CI_PROJECT_ID"
before_script:
- pip install -r requirements.txt
- pip install coverage
test:
script:
- coverage run --source=dpdapi --omit=dpdapi/migrations/* manage.py test -v2
- coverage report
coverage: '/TOTAL.+ ([0-9]{1,3}%)/'
+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"]
+20 -11
View File
@@ -1,14 +1,23 @@
# Install # django-postfix-dovecot-api
apt install python-dev libmysqlclient-dev python-virtualenv
python -m venv venv
. venv/bin/activate
pip install -r requirements.txt
# 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 and then you can do: Used by [mailadmin](https://git.neuf.no/edb/mailadmin).
fab deploy ## Development
# Inspiration: ```sh
- http://modoboa.readthedocs.org/ mise install
- https://github.com/lgunsch/django-vmail uv sync
prek install
uv run python manage.py migrate
uv run python manage.py runserver
uv run pytest
```
API docs are served at `/api/docs`. Override settings (database, secret key, Sentry) in
`dpdapisite/local_settings.py`.
## Deployment
CI builds an image for each push to `main` and deploys it to lynx while the repository variable `DEPLOY_LYNX` is
`true`. The image reads its settings from the environment (`dpdapisite/settings_prod.py`).
+3 -3
View File
@@ -5,9 +5,9 @@ from dpdapi.models import Alias, Domain, User
@admin.register(Alias) @admin.register(Alias)
class AliasAdmin(admin.ModelAdmin): class AliasAdmin(admin.ModelAdmin):
list_display = ['source', 'destination', 'active', 'domain'] list_display = ("source", "destination", "active", "domain")
list_filter = ['domain'] list_filter = ("domain",)
search_fields = ['source', 'destination'] search_fields = ("source", "destination")
@admin.register(Domain) @admin.register(Domain)
+272
View File
@@ -0,0 +1,272 @@
import logging
import operator
from datetime import datetime
from functools import reduce
from typing import Annotated
from django.contrib.auth import authenticate
from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.validators import validate_email
from django.db import models, transaction
from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.utils import timezone
from ninja import NinjaAPI, PatchDict, Query, Schema, Status
from ninja.errors import ValidationError
from ninja.security import HttpBasicAuth, django_auth
from passlib.hash import sha512_crypt
from pydantic import AfterValidator, PlainSerializer, StringConstraints
from dpdapi.models import Alias, Domain, User
logger = logging.getLogger(__name__)
class BasicAuth(HttpBasicAuth):
def authenticate(self, request, username, password):
return authenticate(request, username=username, password=password)
api = NinjaAPI(title="Postfix/Dovecot API", auth=[BasicAuth(), django_auth], urls_namespace="api")
@api.exception_handler(ValidationError)
def validation_error(request, exc):
return api.create_response(request, {"detail": exc.errors}, status=400)
def _email(value: str) -> str:
try:
validate_email(value)
except DjangoValidationError as e:
raise ValueError("Enter a valid email address.") from e
return value.lower()
Email = Annotated[str, StringConstraints(strip_whitespace=True, max_length=254), AfterValidator(_email)]
Name = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=254)]
Password = Annotated[str, StringConstraints(min_length=1, max_length=512)]
# Rendered in the site's time zone, as Django REST framework did.
LocalDateTime = Annotated[datetime, PlainSerializer(lambda v: timezone.localtime(v).isoformat())]
def invalid(field: str, msg: str) -> ValidationError:
return ValidationError([{"loc": ["body", "payload", field], "msg": msg, "type": "value_error"}])
def check_domains(domain_ids: list[int]):
"""Reject domain ids that don't exist."""
if missing := set(domain_ids) - set(Domain.objects.filter(pk__in=domain_ids).values_list("pk", flat=True)):
raise invalid("domain", f"Invalid pk {sorted(missing)} - object does not exist.")
def check_unique(instance: models.Model, field: str, value: str):
qs = type(instance).objects.filter(**{field: value}).exclude(pk=instance.pk)
if qs.exists():
raise invalid(field, f"{instance._meta.verbose_name} with this {field} already exists.")
def not_null(data: dict) -> dict:
"""PatchDict makes every field optional *and* nullable; only the former is wanted."""
for field, value in data.items():
if value is None:
raise invalid(field, "This field may not be null.")
return data
class Timestamped(Schema):
id: int
created: LocalDateTime
updated: LocalDateTime
# Aliases
class AliasKey(Schema):
source: Email
destination: Email
domain: int
class AliasIn(AliasKey):
active: bool = True
class AliasOut(Timestamped):
source: str
destination: str
domain: int
active: bool
@staticmethod
def resolve_domain(obj):
return obj.domain_id
class AliasFilters(Schema):
domain: int | None = None
domain__name: str | None = None
source__regex: str | None = None
source__iregex: str | None = None
destination__regex: str | None = None
destination__iregex: str | None = None
class Result(Schema):
result: str
@api.get("/aliases/", response=list[AliasOut], tags=["aliases"])
def list_aliases(request, filters: Query[AliasFilters]):
return Alias.objects.filter(**filters.model_dump(exclude_none=True))
@api.post("/aliases/create_bulk/", response={201: list[AliasOut]}, tags=["aliases"])
@transaction.atomic
def create_aliases(request, payload: list[AliasIn]):
"""Create aliases, returning existing ones instead of failing on duplicates."""
check_domains([a.domain for a in payload])
aliases = [
Alias.objects.get_or_create(
source=a.source, destination=a.destination, domain_id=a.domain, defaults={"active": a.active}
)[0]
for a in payload
]
return Status(201, aliases)
@api.delete("/aliases/delete_bulk/", response={200: Result, 204: None}, tags=["aliases"])
@transaction.atomic
def delete_aliases(request, payload: list[AliasKey]):
"""Delete aliases, reporting the ones that didn't exist."""
check_domains([a.domain for a in payload])
keys = {(a.source, a.destination, a.domain) for a in payload}
query = reduce(operator.or_, (Q(source=s, destination=d, domain_id=dom) for s, d, dom in keys), Q(pk__in=[]))
aliases = Alias.objects.filter(query)
deleted = set(aliases.values_list("source", "destination", "domain"))
aliases.delete()
logger.debug("Deleted: %s", ",".join(f"{s}={d}" for s, d, _ in sorted(deleted)))
if missing := keys - deleted:
formatted = ", ".join(f"{s}={d}" for s, d, _ in sorted(missing))
result = f"Aliases '{formatted}' could not be found and were not deleted."
logger.info(result)
return Status(200, {"result": result})
return Status(204, None)
# Domains
class DomainIn(Schema):
name: Name
class DomainOut(Timestamped):
name: str
class DomainFilters(Schema):
name: str | None = None
def save_domain(domain: Domain, data: dict) -> Domain:
if "name" in data:
domain.name = data["name"].lower()
check_unique(domain, "name", domain.name)
domain.save()
return domain
@api.get("/domains/", response=list[DomainOut], tags=["domains"])
def list_domains(request, filters: Query[DomainFilters]):
return Domain.objects.filter(**filters.model_dump(exclude_none=True))
@api.post("/domains/", response={201: DomainOut}, tags=["domains"])
def create_domain(request, payload: DomainIn):
return Status(201, save_domain(Domain(), payload.model_dump()))
@api.get("/domains/{int:pk}/", response=DomainOut, tags=["domains"])
def get_domain(request, pk: int):
return get_object_or_404(Domain, pk=pk)
@api.put("/domains/{int:pk}/", response=DomainOut, tags=["domains"])
def update_domain(request, pk: int, payload: DomainIn):
return save_domain(get_object_or_404(Domain, pk=pk), payload.model_dump())
@api.patch("/domains/{int:pk}/", response=DomainOut, tags=["domains"])
def partial_update_domain(request, pk: int, payload: PatchDict[DomainIn]):
return save_domain(get_object_or_404(Domain, pk=pk), not_null(payload))
@api.delete("/domains/{int:pk}/", response={204: None}, tags=["domains"])
def delete_domain(request, pk: int):
get_object_or_404(Domain, pk=pk).delete()
return Status(204, None)
# Users
class UserIn(Schema):
email: Email
password: Password
domain: int
class UserOut(Timestamped):
email: str
domain: int
@staticmethod
def resolve_domain(obj):
return obj.domain_id
def save_user(user: User, data: dict) -> User:
if "email" in data:
user.email = data["email"]
check_unique(user, "email", user.email)
if "domain" in data:
check_domains([data["domain"]])
user.domain_id = data["domain"]
if "password" in data:
user.password = sha512_crypt.hash(data["password"])
user.save()
return user
@api.get("/users/", response=list[UserOut], tags=["users"])
def list_users(request):
return User.objects.all()
@api.post("/users/", response={201: UserOut}, tags=["users"])
def create_user(request, payload: UserIn):
return Status(201, save_user(User(), payload.model_dump()))
@api.get("/users/{int:pk}/", response=UserOut, tags=["users"])
def get_user(request, pk: int):
return get_object_or_404(User, pk=pk)
@api.put("/users/{int:pk}/", response=UserOut, tags=["users"])
def update_user(request, pk: int, payload: UserIn):
return save_user(get_object_or_404(User, pk=pk), payload.model_dump())
@api.patch("/users/{int:pk}/", response=UserOut, tags=["users"])
def partial_update_user(request, pk: int, payload: PatchDict[UserIn]):
return save_user(get_object_or_404(User, pk=pk), not_null(payload))
@api.delete("/users/{int:pk}/", response={204: None}, tags=["users"])
def delete_user(request, pk: int):
get_object_or_404(User, pk=pk).delete()
return Status(204, None)
-18
View File
@@ -1,18 +0,0 @@
import django_filters
from rest_framework.filters import BaseFilterBackend
from dpdapi.models import Alias
class AliasRegexFilter(django_filters.FilterSet):
class Meta:
model = Alias
fields = {
'source': ['iregex', 'regex'],
'destination': ['iregex', 'regex']
}
class AliasRegexFilterBackend(BaseFilterBackend):
def filter_queryset(self, request, queryset, view):
return AliasRegexFilter(request.query_params, queryset=queryset).qs
+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)))
+10 -11
View File
@@ -12,21 +12,21 @@ 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', models.CASCADE) domain = models.ForeignKey("dpdapi.Domain", models.CASCADE)
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
class Meta:
verbose_name_plural = "aliases"
unique_together = ("source", "destination", "domain")
def __str__(self): def __str__(self):
return "{}={}".format(self.source, self.destination) 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):
@@ -38,13 +38,13 @@ class Domain(BaseModel):
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=512) password = models.CharField(max_length=512)
domain = models.ForeignKey('dpdapi.Domain', models.CASCADE) domain = models.ForeignKey("dpdapi.Domain", models.CASCADE)
def __str__(self): def __str__(self):
return self.email return self.email
@@ -52,5 +52,4 @@ class User(BaseModel):
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)
-49
View File
@@ -1,49 +0,0 @@
from rest_framework import serializers
from passlib.hash import sha512_crypt
from .models import Alias, Domain, User
class AliasSerializer(serializers.ModelSerializer):
def create(self, validated_data):
# Workaround to accept duplicates
# FIXME, will fail with existing alias with validated_data['active']=False
alias, created = Alias.objects.get_or_create(**validated_data)
return alias
class Meta:
model = Alias
validators = [] # Disable unique togheter check
fields = '__all__'
class AliasDeleteSerializer(serializers.ModelSerializer):
class Meta:
model = Alias
validators = [] # No Unique togheter check on deletion
fields = '__all__'
class DomainSerializer(serializers.ModelSerializer):
class Meta:
model = Domain
fields = '__all__'
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = '__all__'
extra_kwargs = {'password': {'write_only': True}}
def _hash_password(self, raw_password):
return sha512_crypt.encrypt(raw_password)
def create(self, validated_data):
user = User(
email=validated_data['email'],
domain=validated_data['domain'],
password=self._hash_password(validated_data['password'])
)
user.save()
return user
-43
View File
@@ -1,43 +0,0 @@
import json
from django.contrib.auth.models import User
from django.urls import reverse
from rest_framework.test import APITestCase
from dpdapi.models import Domain, Alias
class RegexTestCase(APITestCase):
aliases = ['ops-alert@example.com', 'yolo-announce@example.com']
def setUp(self):
user = User.objects.create(username='yolo')
self.domain = Domain.objects.create(name='example.com')
for a in self.aliases:
Alias.objects.create(source=a, destination='ninja@example.com', domain=self.domain)
self.client.force_login(user)
def test_get_all(self):
res = self.client.get(reverse('alias-list'))
self.assertEqual(len(res.data), len(self.aliases))
def test_domain_filter(self):
params = {'domain__name': 'example.com'}
res = self.client.get(reverse('alias-list'), data=params)
self.assertEqual(len(res.data), len(self.aliases))
def test_domain_filter_no_hits(self):
params = {'domain__name': 'zexample.com'}
res = self.client.get(reverse('alias-list'), data=params)
self.assertEqual(len(res.data), 0)
def test_regex_filter(self):
params = {'source__regex': '^ops-|^ops@'}
res = self.client.get(reverse('alias-list'), data=params)
self.assertEqual(len(res.data), 1)
def test_delete_bulk_single(self):
data = {'destination': 'ninja@example.com', 'source': 'ding@example.com', 'domain': self.domain.pk}
Alias.objects.create(source=data['source'], destination=data['destination'], domain=self.domain)
res = self.client.delete(reverse('alias-delete-bulk'), data=[data], format='json')
self.assertEqual(res.status_code, 204, res.data)
-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
-76
View File
@@ -1,76 +0,0 @@
import logging
import operator
from functools import reduce
from django.db.models import Q
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.decorators import action
from rest_framework.mixins import ListModelMixin
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status, viewsets
from rest_framework.viewsets import GenericViewSet
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(ListModelMixin, GenericViewSet):
""" Aliases """
queryset = Alias.objects.all()
serializer_class = AliasSerializer
permission_classes = [IsAuthenticated]
filter_backends = (DjangoFilterBackend, AliasRegexFilterBackend)
filterset_fields = ('domain', 'domain__name',)
@action(detail=False, methods=['post'])
def create_bulk(self, request):
serializer = AliasSerializer(data=request.data, many=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(detail=False, methods=['delete'])
def delete_bulk(self, request):
serializer = AliasDeleteSerializer(data=request.data, many=True)
serializer.is_valid(raise_exception=True)
# Ref: http://michelepasin.org/blog/2010/07/20/the-power-of-djangos-q-objects/
q_list = map(lambda x: Q(**x), serializer.initial_data)
aliases = Alias.objects.filter(reduce(operator.or_, q_list))
initial_set = set(map(lambda x: (x['source'], x['destination'], x['domain']), serializer.initial_data))
deleted_set = set(aliases.values_list('source', 'destination', 'domain'))
diff = initial_set.difference(deleted_set)
logger.debug('Deleted: {}'.format(','.join(map(lambda x: '{0[0]}={0[1]}'.format(x), deleted_set))))
aliases.delete()
if diff:
diff_formatted = map(lambda x: '{0[0]}={0[1]}'.format(x), diff)
res = "Aliases '{}' could not be found and were not deleted.".format(", ".join(diff_formatted))
logger.info(res)
return Response({"result": res}, status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
class DomainViewSet(viewsets.ModelViewSet):
""" Domains """
queryset = Domain.objects.all()
serializer_class = DomainSerializer
permission_classes = [IsAuthenticated]
filter_backends = (DjangoFilterBackend,)
filterset_fields = ('name',)
class UserViewSet(viewsets.ModelViewSet):
""" Users """
queryset = User.objects.all()
serializer_class = UserSerializer
permission_classes = [IsAuthenticated]
+66 -89
View File
@@ -1,25 +1,22 @@
""" """
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
import sentry_sdk BASE_DIR = Path(__file__).resolve().parent.parent
from sentry_sdk.integrations.django import DjangoIntegration
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
# 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
@@ -28,123 +25,103 @@ ALLOWED_HOSTS = []
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'django.contrib.admin', "django.contrib.admin",
'django.contrib.messages', "django.contrib.messages",
'django.contrib.auth', "django.contrib.auth",
'django.contrib.contenttypes', "django.contrib.contenttypes",
'django.contrib.sessions', "django.contrib.sessions",
'django.contrib.staticfiles', "django.contrib.staticfiles",
"django_extensions",
'django_extensions', "ninja",
'rest_framework',
'django_filters',
] ]
INSTALLED_APPS += [ INSTALLED_APPS += [
'dpdapi', "dpdapi",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', "django.middleware.security.SecurityMiddleware",
'django.contrib.sessions.middleware.SessionMiddleware', "whitenoise.middleware.WhiteNoiseMiddleware",
'django.middleware.common.CommonMiddleware', "django.contrib.sessions.middleware.SessionMiddleware",
'django.middleware.csrf.CsrfViewMiddleware', "django.middleware.common.CommonMiddleware",
'django.contrib.auth.middleware.AuthenticationMiddleware', "django.middleware.csrf.CsrfViewMiddleware",
'django.middleware.clickjacking.XFrameOptionsMiddleware', "django.contrib.auth.middleware.AuthenticationMiddleware",
'django.contrib.messages.middleware.MessageMiddleware', "django.middleware.clickjacking.XFrameOptionsMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
] ]
ROOT_URLCONF = 'dpdapisite.urls' ROOT_URLCONF = "dpdapisite.urls"
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', "BACKEND": "django.template.backends.django.DjangoTemplates",
'DIRS': [ "DIRS": [
# insert your TEMPLATE_DIRS here # insert your TEMPLATE_DIRS here
], ],
'APP_DIRS': True, "APP_DIRS": True,
'OPTIONS': { "OPTIONS": {
'context_processors': [ "context_processors": [
'django.template.context_processors.debug', "django.template.context_processors.debug",
'django.template.context_processors.request', "django.template.context_processors.request",
'django.contrib.auth.context_processors.auth', "django.contrib.auth.context_processors.auth",
'django.contrib.messages.context_processors.messages', "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/"
REST_FRAMEWORK = { # Postfix and Dovecot read these tables directly; keep the existing integer id columns.
'DEFAULT_AUTHENTICATION_CLASSES': ( DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
'rest_framework.authentication.BasicAuthentication', STATIC_ROOT = BASE_DIR / "static"
'rest_framework.authentication.SessionAuthentication', STORAGES = {
) "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
} }
LOGGING = { LOGGING = {
'version': 1, "version": 1,
'disable_existing_loggers': False, "disable_existing_loggers": False,
'root': { "root": {
'level': 'INFO', "level": "INFO",
'handlers': ['console'], "handlers": ["console"],
}, },
'formatters': { "formatters": {
'verbose': { "verbose": {"format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s"},
'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s'
}, },
}, "handlers": {"console": {"level": "DEBUG", "class": "logging.StreamHandler", "formatter": "verbose"}},
'handlers': { "loggers": {
'console': { "django.db.backends": {
'level': 'DEBUG', "level": "ERROR",
'class': 'logging.StreamHandler', "handlers": ["console"],
'formatter': 'verbose' "propagate": False,
}
},
'loggers': {
'django.db.backends': {
'level': 'ERROR',
'handlers': ['console'],
'propagate': False,
}, },
}, },
} }
SENTRY_DSN = os.getenv('SENTRY_DSN', 'https://c7e6a47844484c0a81a98e10e48c1b71@sentry.neuf.no/8') with suppress(ImportError):
if SENTRY_DSN: from .local_settings import * # noqa: F403
SENTRY_ENVIRONMENT = os.getenv('SENTRY_ENVIRONMENT', 'production')
sentry_sdk.init(
dsn=SENTRY_DSN,
environment=SENTRY_ENVIRONMENT,
integrations=[DjangoIntegration()]
)
try:
from .local_settings import *
except ImportError:
pass
+44
View File
@@ -0,0 +1,44 @@
"""Settings for the container image, from environment variables."""
import os
from dpdapisite.settings import * # noqa: F403
DEBUG = False
SECRET_KEY = os.environ["SECRET_KEY"]
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "").split(",")
CSRF_TRUSTED_ORIGINS = [f"https://{host}" for host in ALLOWED_HOSTS if host]
# A DB_HOST starting with / is a unix socket
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": os.getenv("DB_NAME", "prod_mail"),
"USER": os.getenv("DB_USER", "mailserver"),
"PASSWORD": os.environ["DB_PASSWORD"],
"HOST": os.getenv("DB_HOST", "/run/mysqld/mysqld.sock"),
"CONN_MAX_AGE": 60,
"CONN_HEALTH_CHECKS": True,
},
}
# TLS ends at the host's nginx
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
if dsn := os.getenv("SENTRY_DSN"):
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
sentry_sdk.init(
dsn=dsn,
environment=os.getenv("SENTRY_ENVIRONMENT", "production"),
release=os.getenv("SENTRY_RELEASE", "N/A"),
integrations=[DjangoIntegration()],
send_default_pii=False,
traces_sample_rate=0,
send_client_reports=False,
auto_session_tracking=False,
)
+5 -8
View File
@@ -1,14 +1,11 @@
from django.conf.urls import 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
from rest_framework import urls as rest_framework_urls from dpdapi.api import api
from dpdapi import urls as dpdapi_urls
urlpatterns = [ urlpatterns = [
url(r'^$', RedirectView.as_view(pattern_name='api-root')), path("", RedirectView.as_view(pattern_name="api:openapi-view")),
url(r'^api/', include(dpdapi_urls)), path("api/", api.urls),
url(r'^admin/', admin.site.urls), path("admin/", admin.site.urls),
url(r'^api-auth/', include(rest_framework_urls, namespace='rest_framework'))
] ]
+1 -9
View File
@@ -1,13 +1,5 @@
"""
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
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dpdapisite.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dpdapisite.settings")
Vendored
-22
View File
@@ -1,22 +0,0 @@
import os
from fabric import Connection
from invoke import task
@task()
def deploy(c):
"""Make sure proxy_user is set to your neuf username."""
project_path = '/var/www/neuf.no/django-postfix-dovecot-api'
proxy_user = os.getenv('DEPLOY_USER', os.getenv('USER'))
c = Connection(host='gitdeploy@lynx.neuf.no', gateway=Connection('login.neuf.no', user=proxy_user))
with c.cd(project_path), c.prefix('source {}/venv/bin/activate'.format(project_path)):
c.run('git pull') # Get source
c.run('pip install -r requirements.txt') # install deps in virtualenv
c.run('umask 022; python manage.py collectstatic --noinput') # Collect static
c.run('python manage.py migrate') # Run DB migrations
# Reload mxapi.neuf.no
c.sudo('/usr/bin/supervisorctl pid mxapi.neuf.no | xargs kill -HUP', shell=False)
+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"]
-9
View File
@@ -1,9 +0,0 @@
Django==2.2.*
fabric==2.5.*
django-extensions==2.2.*
django-filter==2.2.*
djangorestframework==3.11.*
gunicorn==20.0.*
mysqlclient==1.4.6
passlib==1.7.*
sentry-sdk==0.13.*
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" },
]