1 Commits
38 changed files with 410 additions and 1834 deletions

No files matched your search

-10
View File
@@ -1,10 +0,0 @@
.git
.venv
venv
**/__pycache__
.pytest_cache
.ruff_cache
.coverage
db.sqlite3
static
dpdapisite/local_settings.py
-80
View File
@@ -1,80 +0,0 @@
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 }}"
+1 -5
View File
@@ -1,10 +1,6 @@
*.swp *.swp
*.pyc *.pyc
.idea .idea
venv
local_settings.py local_settings.py
db.sqlite3 db.sqlite3
venv
.venv
.coverage
.pytest_cache
/static
-31
View File
@@ -1,31 +0,0 @@
# 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
@@ -1,31 +0,0 @@
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 -17
View File
@@ -1,23 +1,26 @@
# django-postfix-dovecot-api # Install
apt install python-dev libmysqlclient-dev python-virtualenv
virtualenv venv
. venv/bin/activate
pip install -r requirements
REST API for managing the virtual domains, users and aliases that Postfix and Dovecot read from the database. # Deploy
Used by [mailadmin](https://git.neuf.no/edb/mailadmin). First you need to have your public key in /home/gitdeploy/.ssh/authorized_keys on the server.
## Development Then, to deploy via login.neuf.no, add the following to your personal ~/.ssh/config
```sh Host lynx.neuf.no
mise install User gitdeploy
uv sync ForwardAgent yes
prek install ProxyCommand ssh -W %h:%p login.neuf.no -l USERNAME
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 Then you can do
`dpdapisite/local_settings.py`.
## Deployment fab deploy
# TODO
- Domain Aliases
CI builds an image for each push to `main` and deploys it to lynx while the repository variable `DEPLOY_LYNX` is # Inspiration:
`true`. The image reads its settings from the environment (`dpdapisite/settings_prod.py`). - http://modoboa.readthedocs.org/
- https://github.com/lgunsch/django-vmail
+4 -4
View File
@@ -5,9 +5,9 @@ from dpdapi.models import Alias, Domain, User
@admin.register(Alias) @admin.register(Alias)
class AliasAdmin(admin.ModelAdmin): class AliasAdmin(admin.ModelAdmin):
list_display = ("source", "destination", "active", "domain") list_display = ['source', 'destination', 'active', 'domain']
list_filter = ("domain",) list_filter = ['domain']
search_fields = ("source", "destination") search_fields = ['source', 'destination']
@admin.register(Domain) @admin.register(Domain)
@@ -17,4 +17,4 @@ class DomainAdmin(admin.ModelAdmin):
@admin.register(User) @admin.register(User)
class UserAdmin(admin.ModelAdmin): class UserAdmin(admin.ModelAdmin):
pass pass
-272
View File
@@ -1,272 +0,0 @@
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)
+40
View File
@@ -0,0 +1,40 @@
from django.core.exceptions import ValidationError
from django.utils import six
from rest_framework.fields import CharField, EmailValidator
from django.utils.translation import ugettext_lazy as _
def domain_alias_validator(value):
if not value.startswith('@'):
raise ValidationError('Not valid')
EmailValidator().validate_domain_part(value.split('@'))
class EmailOrDomainAliasField(CharField):
default_error_messages = {
'invalid': _('Enter a valid email address or domain alias.'),
}
def to_internal_value(self, data):
return six.text_type(data).strip()
def to_representation(self, value):
email_validator = EmailValidator(message=self.default_error_messages['invalid'])
# Try email validation first
try:
email_validator(value)
return six.text_type(value).strip() # Valid email
except ValidationError:
pass
# Try domain forward validation
try:
domain_alias_validator(value)
return six.text_type(value).strip() # Valid domain alias
except ValidationError:
pass
raise ValidationError(self.default_error_messages, code='invalid')
+17
View File
@@ -0,0 +1,17 @@
import django_filters
from dpdapi.models import Alias
from rest_framework.filters import BaseFilterBackend
class AliasRegexFilter(django_filters.FilterSet):
# Ref: https://docs.djangoproject.com/en/1.7/ref/models/querysets/#iregex
source_regex = django_filters.CharFilter(name="source", lookup_type='iregex')
class Meta:
model = Alias
class AliasRegexFilterBackend(BaseFilterBackend):
def filter_queryset(self, request, queryset, view):
return AliasRegexFilter(request.query_params, queryset=queryset).qs
+36 -36
View File
@@ -1,60 +1,60 @@
from pathlib import Path
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import IntegrityError, transaction from django.db.utils import IntegrityError
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 add_arguments(self, parser): def handle(self, *args, **options):
parser.add_argument("filename", nargs="?") if len(args) == 1:
if os.path.exists(args[0]):
def handle(self, *args, filename=None, **options): self.import_file(args[0])
if filename is None: else:
self.stdout.write("Missing argument") self.stdout.write("{} does not exist".format(args[0]))
elif not Path(filename).exists():
self.stdout.write(f"{filename} does not exist")
else: else:
self.import_file(filename) self.stdout.write("Missing argument")
def import_file(self, filename): def import_file(self, filename):
new_aliases = [] new_aliases = []
counts = {"imported": 0, "existing": 0} counts = {'imported': 0, 'existing': 0}
with Path(filename).open() as f: # Open file
for line in f: with open(filename) as f:
if line.startswith("*: "): # Parse
for l in f:
if l.startswith('*: '):
continue # Skip catch all continue # Skip catch all
source, destinations = line.strip().split(": ") source, destinations = l.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 not new_aliases: if len(new_aliases) == 0:
self.stdout.write("No aliases found") self.stdout.write('No aliases found')
return return
# Assumes that every alias is in the same domain # Lookup domain
domain_name = new_aliases[0].source.split("@")[1] domain_name = new_aliases[0].source.split('@')[1] # Assumes that every alias is in the same domain
domain, created = Domain.objects.get_or_create(name=domain_name) d, created = Domain.objects.get_or_create(name=domain_name)
if created: if created:
self.stdout.write(f"Created domain {domain.name}.") self.stdout.write('Created domain {}.'.format(d.name))
# Try to create, log if exists. Slow but works # Try to create, log if exists. Slow but works
for alias in new_aliases: for a in new_aliases:
alias.domain = domain a.domain = d
try: try:
with transaction.atomic(): a.save()
alias.save() counts['imported'] += 1 # Woop!
counts["imported"] += 1
except IntegrityError: except IntegrityError:
counts["existing"] += 1 counts['existing'] += 1 # Exists
self.stdout.write(f"Could not import '{alias.source}={alias.destination}', already exists.") self.stdout.write('Could not import \'{}={}\', already exists.'.format(a.source, a.destination))
self.stdout.write( # Print num imported, existing, total
f"Imported {counts['imported']}, skipped {counts['existing']} existing, total {len(new_aliases)}" self.stdout.write('Imported {}, skipped {} existing, total {}'.format(
) counts['imported'],
counts['existing'],
len(new_aliases)))
+2 -2
View File
@@ -46,7 +46,7 @@ class Migration(migrations.Migration):
('updated', models.DateTimeField(auto_now=True)), ('updated', models.DateTimeField(auto_now=True)),
('email', models.EmailField(unique=True, max_length=254)), ('email', models.EmailField(unique=True, max_length=254)),
('password', models.CharField(max_length=109)), ('password', models.CharField(max_length=109)),
('domain', models.ForeignKey(to='dpdapi.Domain', on_delete=models.CASCADE)), ('domain', models.ForeignKey(to='dpdapi.Domain')),
], ],
options={ options={
'abstract': False, 'abstract': False,
@@ -56,7 +56,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='alias', model_name='alias',
name='domain', name='domain',
field=models.ForeignKey(to='dpdapi.Domain', on_delete=models.CASCADE), field=models.ForeignKey(to='dpdapi.Domain'),
preserve_default=True, preserve_default=True,
), ),
] ]
@@ -1,18 +0,0 @@
# Generated by Django 2.2.16 on 2020-09-09 21:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dpdapi', '0002_auto_20150118_1638'),
]
operations = [
migrations.AlterField(
model_name='user',
name='password',
field=models.CharField(max_length=512),
),
]
+16 -14
View File
@@ -1,3 +1,4 @@
from __future__ import unicode_literals
from django.db import models from django.db import models
@@ -12,44 +13,45 @@ 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')
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
class Meta: def __unicode__(self):
verbose_name_plural = "aliases" return "{}={}".format(self.source, self.destination)
unique_together = ("source", "destination", "domain")
def __str__(self):
return f"{self.source}={self.destination}"
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.source = self.source.lower() self.source = self.source.lower()
self.destination = self.destination.lower() self.destination = self.destination.lower()
super().save(*args, **kwargs) super(Alias, self).save(*args, **kwargs)
class Meta:
verbose_name_plural = 'aliases'
unique_together = ('source', 'destination', 'domain')
class Domain(BaseModel): class Domain(BaseModel):
name = models.CharField(max_length=254, unique=True) name = models.CharField(max_length=254, unique=True)
def __str__(self): def __unicode__(self):
return self.name return self.name
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.name = self.name.lower() self.name = self.name.lower()
super().save(*args, **kwargs) super(Domain, self).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=109)
domain = models.ForeignKey("dpdapi.Domain", models.CASCADE) domain = models.ForeignKey('dpdapi.Domain')
def __str__(self): def __unicode__(self):
return self.email return self.email
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.email = self.email.lower() self.email = self.email.lower()
super().save(*args, **kwargs) super(User, self).save(*args, **kwargs)
+41
View File
@@ -0,0 +1,41 @@
from rest_framework import serializers
from passlib.hash import sha512_crypt
from .models import Alias, Domain, User
from .fields import EmailOrDomainAliasField
class AliasSerializer(serializers.ModelSerializer):
source = EmailOrDomainAliasField()
destination = EmailOrDomainAliasField()
class Meta:
model = Alias
class AliasDeleteSerializer(serializers.ModelSerializer):
class Meta:
model = Alias
validators = [] # No Unique togheter check on deletion
class DomainSerializer(serializers.ModelSerializer):
class Meta:
model = Domain
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
extra_kwargs = {'password': {'write_only': True}}
def _hash_password(self, raw_password):
return sha512_crypt.encrypt(raw_password)
def create(self, validated_data):
user = User(
email=validated_data['email'],
domain=validated_data['domain'],
password=self._hash_password(validated_data['password'])
)
user.save()
return user
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+10
View File
@@ -0,0 +1,10 @@
from rest_framework.routers import DefaultRouter
from .views import AliasViewSet, DomainViewSet, UserViewSet
router = DefaultRouter()
router.register(r'aliases', AliasViewSet)
router.register(r'domains', DomainViewSet)
router.register(r'users', UserViewSet)
urlpatterns = router.urls
+73
View File
@@ -0,0 +1,73 @@
from __future__ import unicode_literals
import logging
import operator
from django.db.models import Q
from rest_framework.decorators import list_route
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status, filters, viewsets
from dpdapi.filters import AliasRegexFilterBackend
from dpdapi.models import Alias, Domain, User
from dpdapi.serializers import AliasSerializer, AliasDeleteSerializer, DomainSerializer, UserSerializer
logger = logging.getLogger(__name__)
class AliasViewSet(viewsets.ModelViewSet):
""" Aliases """
queryset = Alias.objects.all()
serializer_class = AliasSerializer
permission_classes = [IsAuthenticated]
filter_backends = (filters.DjangoFilterBackend, AliasRegexFilterBackend)
filter_fields = ('domain', 'domain__name',)
@list_route(methods=['post'])
def create_bulk(self, request):
serializer = AliasSerializer(data=request.data, many=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
@list_route(methods=['delete'])
def delete_bulk(self, request):
serializer = AliasDeleteSerializer(data=request.data, many=True)
serializer.is_valid(raise_exception=True)
# Ref: http://michelepasin.org/blog/2010/07/20/the-power-of-djangos-q-objects/
q_list = map(lambda x: Q(**x), serializer.initial_data)
aliases = Alias.objects.filter(reduce(operator.or_, q_list))
initial_set = set(map(lambda x: (x['source'], x['destination'], x['domain']), serializer.initial_data))
deleted_set = set(aliases.values_list('source', 'destination', 'domain'))
diff = initial_set.difference(deleted_set)
logger.debug('Deleted: {}'.format(','.join(map(lambda x: '{0[0]}={0[1]}'.format(x), deleted_set))))
aliases.delete()
if diff:
diff_formatted = map(lambda x: '{0[0]}={0[1]}'.format(x), diff)
res = "Aliases '{}' could not be found and were not deleted.".format(", ".join(diff_formatted))
logger.info(res)
return Response({"result": res}, status=status.HTTP_200_OK)
return Response(status=status.HTTP_204_NO_CONTENT)
class DomainViewSet(viewsets.ModelViewSet):
""" Domains """
queryset = Domain.objects.all()
serializer_class = DomainSerializer
permission_classes = [IsAuthenticated]
filter_backends = (filters.DjangoFilterBackend,)
filter_fields = ('name',)
class UserViewSet(viewsets.ModelViewSet):
""" Users """
queryset = User.objects.all()
serializer_class = UserSerializer
permission_classes = [IsAuthenticated]
+59 -88
View File
@@ -1,127 +1,98 @@
""" """
For more information on this file, see For more information on this file, see
https://docs.djangoproject.com/en/stable/topics/settings/ https://docs.djangoproject.com/en/1.7/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/stable/ref/settings/ https://docs.djangoproject.com/en/1.7/ref/settings/
""" """
from contextlib import suppress # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
from pathlib import Path import os
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/stable/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/1.7/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 = "django-insecure-hlaf#yma(8s!cy-bf5e6kc&_5n_l1h!34c(j32-b3v8o7pg$5n" # noqa: S105 SECRET_KEY = 'hlaf#yma(8s!cy-bf5e6kc&_5n_l1h!34c(j32-b3v8o7pg$5n'
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
TEMPLATE_DEBUG = True
ALLOWED_HOSTS = [] ALLOWED_HOSTS = []
# Application definition # Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.messages",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.staticfiles",
"django_extensions",
"ninja",
]
INSTALLED_APPS += [ INSTALLED_APPS = (
"dpdapi", 'django.contrib.admin',
] 'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
)
MIDDLEWARE = [ INSTALLED_APPS += (
"django.middleware.security.SecurityMiddleware", 'rest_framework',
"whitenoise.middleware.WhiteNoiseMiddleware", 'dpdapi',
"django.contrib.sessions.middleware.SessionMiddleware", )
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
]
ROOT_URLCONF = "dpdapisite.urls" MIDDLEWARE_CLASSES = (
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
)
TEMPLATES = [ ROOT_URLCONF = 'dpdapisite.urls'
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [
# insert your TEMPLATE_DIRS here
],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "dpdapisite.wsgi.application" WSGI_APPLICATION = 'dpdapisite.wsgi.application'
# Database # Database
# https://docs.djangoproject.com/en/stable/ref/settings/#databases # https://docs.djangoproject.com/en/1.7/ref/settings/#databases
DATABASES = { DATABASES = {
"default": { 'default': {
"ENGINE": "django.db.backends.sqlite3", 'ENGINE': 'django.db.backends.sqlite3',
"NAME": BASE_DIR / "db.sqlite3", 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
} }
} }
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/stable/topics/i18n/ # https://docs.djangoproject.com/en/1.7/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "Europe/Oslo" LANGUAGE_CODE = 'en-us'
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/stable/howto/static-files/ # https://docs.djangoproject.com/en/1.7/howto/static-files/
STATIC_URL = "/static/"
# Postfix and Dovecot read these tables directly; keep the existing integer id columns. STATIC_URL = '/static/'
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
STATIC_ROOT = BASE_DIR / "static" REST_FRAMEWORK = {
STORAGES = { 'DEFAULT_AUTHENTICATION_CLASSES': (
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, 'rest_framework.authentication.BasicAuthentication',
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"}, 'rest_framework.authentication.SessionAuthentication',
)
} }
try:
LOGGING = { from local_settings import *
"version": 1, except ImportError:
"disable_existing_loggers": False, pass
"root": {
"level": "INFO",
"handlers": ["console"],
},
"formatters": {
"verbose": {"format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s"},
},
"handlers": {"console": {"level": "DEBUG", "class": "logging.StreamHandler", "formatter": "verbose"}},
"loggers": {
"django.db.backends": {
"level": "ERROR",
"handlers": ["console"],
"propagate": False,
},
},
}
with suppress(ImportError):
from .local_settings import * # noqa: F403
-44
View File
@@ -1,44 +0,0 @@
"""Settings for the container image, from environment variables."""
import os
from dpdapisite.settings import * # noqa: F403
DEBUG = False
SECRET_KEY = os.environ["SECRET_KEY"]
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "").split(",")
CSRF_TRUSTED_ORIGINS = [f"https://{host}" for host in ALLOWED_HOSTS if host]
# A DB_HOST starting with / is a unix socket
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": os.getenv("DB_NAME", "prod_mail"),
"USER": os.getenv("DB_USER", "mailserver"),
"PASSWORD": os.environ["DB_PASSWORD"],
"HOST": os.getenv("DB_HOST", "/run/mysqld/mysqld.sock"),
"CONN_MAX_AGE": 60,
"CONN_HEALTH_CHECKS": True,
},
}
# TLS ends at the host's nginx
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
if dsn := os.getenv("SENTRY_DSN"):
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
sentry_sdk.init(
dsn=dsn,
environment=os.getenv("SENTRY_ENVIRONMENT", "production"),
release=os.getenv("SENTRY_RELEASE", "N/A"),
integrations=[DjangoIntegration()],
send_default_pii=False,
traces_sample_rate=0,
send_client_reports=False,
auto_session_tracking=False,
)
+8 -8
View File
@@ -1,11 +1,11 @@
from django.conf.urls import patterns, include, url
from django.contrib import admin from django.contrib import admin
from django.urls import path
from django.views.generic import RedirectView from django.views.generic import RedirectView
from dpdapi.api import api urlpatterns = patterns(
'',
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/', include(admin.site.urls)),
path("admin/", admin.site.urls), url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework'))
] )
+10 -3
View File
@@ -1,7 +1,14 @@
"""
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
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dpdapisite.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dpdapisite.settings")
from django.core.wsgi import get_wsgi_application
application = get_wsgi_application() application = get_wsgi_application()
Vendored
+63
View File
@@ -0,0 +1,63 @@
import logging
import sys
from contextlib import contextmanager as _contextmanager
from fabric.api import run, sudo, env, cd, prefix, lcd
from fabric.api import local as lrun
env.use_ssh_config = True
env.hosts = ['lynx.neuf.no']
env.project_path = '/var/www/neuf.no/django-postfix-dovecot-api'
env.user = 'gitdeploy'
env.activate = 'source {}/venv/bin/activate'.format(env.project_path)
@_contextmanager
def virtualenv():
with cd(env.project_path):
with prefix(env.activate):
yield
def __git_validate_clean():
with lcd(env.project_path):
if lrun('git diff-files', capture=True) != '':
logging.error("Your git-tree isn't clean! Commit your changes before deploying.")
sys.exit(1)
def test():
lrun("python manage.py test dpdapi")
def commit():
lrun("git add -p && git commit")
def push():
lrun("git push")
def prepare_deploy():
test()
commit()
push()
def gitstatus():
# __git_validate_clean()
with virtualenv():
# Get source
run('git branch -v')
def deploy():
# __git_validate_clean()
with virtualenv():
run('git pull') # Get source
run('pip install -r requirements.txt') # install deps in virtualenv
run('umask 022; python manage.py collectstatic --noinput') # Collect static
run('python manage.py migrate') # Run DB migrations
# Reload gunicorn
sudo('/etc/init.d/gunicorn reload', shell=False)
-7
View File
@@ -1,7 +0,0 @@
[tools]
python = "3.14"
uv = "latest"
prek = "latest"
[env]
_.python.venv = { path = ".venv", create = false }
-39
View File
@@ -1,39 +0,0 @@
#: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
@@ -1,59 +0,0 @@
[project]
name = "django-postfix-dovecot-api"
version = "0.1.0"
description = "REST API for managing Postfix/Dovecot virtual domains, users and aliases"
readme = "README.md"
requires-python = "~=3.14.0"
dependencies = [
"django>=5.2,<6",
"django-extensions>=4.1",
"django-ninja>=1.7.3",
"libpass>=1.9.4",
"sentry-sdk>=2.73.0",
"whitenoise>=6.12.0",
]
[dependency-groups]
dev = [
"pytest>=9.2.0",
"pytest-cov>=7.1.0",
"pytest-django>=4.14.0",
"ruff>=0.17.3",
]
prod = [
"gunicorn>=26.2.0",
"mysqlclient>=2.3.0",
]
[tool.uv]
package = false
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "dpdapisite.settings"
testpaths = ["tests"]
pythonpath = ["."]
filterwarnings = ["error"]
addopts = "--cov --cov-report=term-missing"
[tool.coverage.run]
source = ["dpdapi", "dpdapisite"]
omit = ["*/migrations/*", "dpdapisite/settings.py", "dpdapisite/settings_prod.py"]
branch = true
[tool.coverage.report]
fail_under = 100
show_missing = true
[tool.ruff]
line-length = 120
target-version = "py314"
extend-exclude = ["*/migrations/*"]
force-exclude = true
[tool.ruff.lint]
select = [
"E", "W", "F", "I", "UP", "B", "A", "C4", "DJ", "S", "SIM", "PT", "PTH", "RET", "RUF", "N", "LOG", "G",
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101", "S105", "S106"]
+7
View File
@@ -0,0 +1,7 @@
Django>=1.7,<1.8
django-extensions==1.4.9
django-filter==0.9.1
djangorestframework>=3.0,<3.1
passlib==1.6.2
mysqlclient==1.3.4
Fabric==1.10.1
View File
Whitespace-only changes.
-34
View File
@@ -1,34 +0,0 @@
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
@@ -1,160 +0,0 @@
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
@@ -1,48 +0,0 @@
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
@@ -1,98 +0,0 @@
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
@@ -1,61 +0,0 @@
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
@@ -1,83 +0,0 @@
"""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
@@ -1,24 +0,0 @@
"""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
@@ -1,23 +0,0 @@
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
@@ -1,107 +0,0 @@
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
@@ -1,428 +0,0 @@
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" },
]