Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7efa230deb
|
||
|
|
8f58d7fcf9
|
||
|
|
7cd4b5dfc6
|
||
|
|
a21b4c9417
|
||
|
|
5474ad7bdc
|
||
|
|
9350064c63
|
||
|
|
712f370245
|
||
|
|
cad499c19a
|
||
|
|
08ffd39674
|
||
|
|
5ef366f667
|
||
|
|
83eb8f07f2
|
||
|
|
cd8de0847c
|
||
|
|
dd634d3717
|
||
|
|
3b9a43fc7a | ||
|
|
1104c64c2a | ||
|
|
946af247c6 | ||
|
|
35929d755a | ||
|
|
e3567ed06d | ||
|
|
861f890567 | ||
|
|
e3fcb4b543 | ||
|
|
8c0c84eb49 | ||
|
|
70be41df96 | ||
|
|
3daa708ea2 | ||
|
|
7ad5191361 | ||
|
|
39f4ee654a | ||
|
|
053c02d8bc | ||
|
|
f0a7858811 | ||
|
|
2a037fbe8f | ||
|
|
4e41af0bd8 | ||
|
|
ad025f17d8 | ||
|
|
2a2baef6d2 | ||
|
|
5efea835d0 | ||
|
|
88eeabe746 | ||
|
|
c5654d7993 | ||
|
|
243f480ce2 |
No files matched your search
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.venv
|
||||
venv
|
||||
**/__pycache__
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.coverage
|
||||
db.sqlite3
|
||||
static
|
||||
dpdapisite/local_settings.py
|
||||
@@ -0,0 +1,80 @@
|
||||
name: CI
|
||||
|
||||
# Tests and prek on every push and pull request. Pushes to main deploy to lynx while DEPLOY_LYNX is true.
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install mise
|
||||
run: |
|
||||
curl -fsSL https://mise.run | sh
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install pinned tools
|
||||
run: mise install
|
||||
|
||||
# The prod group needs MySQL headers; tests run on SQLite.
|
||||
- name: Install Python dependencies
|
||||
run: mise exec -- uv sync --locked --no-group prod
|
||||
|
||||
- name: Unit tests
|
||||
run: mise exec -- uv run --no-sync pytest
|
||||
|
||||
# The hooks in prek.toml.
|
||||
prek:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: j178/prek-action@v1
|
||||
|
||||
# kaniko: the runner has no docker socket
|
||||
image:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.DEPLOY_LYNX == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- tests
|
||||
- prek
|
||||
container: ghcr.io/osscontainertools/kaniko:v1.28.6-debug
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
steps:
|
||||
- name: Build and push
|
||||
env:
|
||||
GIT_USERNAME: ${{ github.actor }}
|
||||
GIT_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
auth=$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_TOKEN" | base64 | tr -d '\n')
|
||||
printf '{"auths":{"git.neuf.no":{"auth":"%s"}}}' "$auth" > /kaniko/.docker/config.json
|
||||
/kaniko/executor \
|
||||
--context "git://git.neuf.no/${{ github.repository }}.git#${{ github.ref }}#${{ github.sha }}" \
|
||||
--destination "git.neuf.no/edb/django-postfix-dovecot-api:${{ github.sha }}" \
|
||||
--build-arg "SENTRY_RELEASE=${{ github.sha }}" \
|
||||
--cache=true --cache-repo git.neuf.no/edb/django-postfix-dovecot-api-cache \
|
||||
--snapshot-mode=redo --use-new-run \
|
||||
--reproducible
|
||||
|
||||
# The key's forced command deploys the sha it is given
|
||||
deploy-lynx:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.DEPLOY_LYNX == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- image
|
||||
steps:
|
||||
- name: Deploy
|
||||
run: |
|
||||
command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )
|
||||
eval $(ssh-agent -s)
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" | tr -d '\r' | ssh-add -
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
echo "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
|
||||
ssh mxapi-deploy@lynx.neuf.no "${{ github.sha }}"
|
||||
+5
-1
@@ -1,6 +1,10 @@
|
||||
*.swp
|
||||
*.pyc
|
||||
.idea
|
||||
venv
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
venv
|
||||
.venv
|
||||
.coverage
|
||||
.pytest_cache
|
||||
/static
|
||||
@@ -0,0 +1,31 @@
|
||||
# AGENTS.md
|
||||
|
||||
REST API (Django + django-ninja) for managing the virtual domains, users and aliases that Postfix and Dovecot read
|
||||
from the database. Deployed as mxapi.neuf.no.
|
||||
|
||||
## Setup and commands
|
||||
|
||||
Tools are managed by [mise](https://mise.jdx.dev) (Python, uv, prek) and Python deps by uv.
|
||||
|
||||
```sh
|
||||
mise install # Python, uv, prek
|
||||
uv sync # deps + dev tools
|
||||
prek install # git hooks
|
||||
uv run pytest # tests, must keep 100% coverage
|
||||
prek run --all-files # ruff check, ruff format and file hygiene hooks
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- Use [Conventional Commits](https://www.conventionalcommits.org/) for commit messages, e.g.
|
||||
`feat: add alias search`, `fix(api): reject unknown domains`, `chore(deps): bump django`.
|
||||
- Postfix and Dovecot query the `dpdapi_domain`, `dpdapi_alias` and `dpdapi_user` tables directly. Never rename
|
||||
tables or columns, or change how values are stored (addresses are lowercased, passwords are SHA-512 crypt).
|
||||
`tests/test_schema.py` guards this.
|
||||
- Django stays on 5.2 LTS (and Python 3.14) until lynx has MySQL 8.4, which Django 6 requires.
|
||||
- [mailadmin](https://git.neuf.no/edb/mailadmin) is the main API client. Don't change URLs, status codes or response fields it relies on;
|
||||
`tests/test_mailadmin_contract.py` replays its calls.
|
||||
- Test the API through HTTP in `tests/`, using pytest and the fixtures in `tests/conftest.py`.
|
||||
- Keep coverage at 100%; `pytest` fails below it.
|
||||
- Production-only deps (`gunicorn`, `mysqlclient`) live in the `prod` dependency group. Production settings come from
|
||||
the environment (`dpdapisite/settings_prod.py`).
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
FROM python:3.14-slim-trixie AS build
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.13.6 /uv /bin/uv
|
||||
# mysqlclient ships no wheels
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential pkg-config default-libmysqlclient-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Outside /app, so a code change leaves the venv layer alone
|
||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never UV_PROJECT_ENVIRONMENT=/venv
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --locked --no-dev --group prod --no-install-project
|
||||
COPY . .
|
||||
RUN DJANGO_SETTINGS_MODULE=dpdapisite.settings_prod SECRET_KEY=collectstatic DB_PASSWORD=collectstatic \
|
||||
/venv/bin/python manage.py collectstatic --noinput
|
||||
|
||||
FROM python:3.14-slim-trixie
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libmariadb3 \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --system --uid 934 --home-dir /app --no-create-home mxapi
|
||||
COPY --from=build /venv /venv
|
||||
COPY --from=build /app /app
|
||||
WORKDIR /app
|
||||
ARG SENTRY_RELEASE=N/A
|
||||
ENV PATH=/venv/bin:$PATH \
|
||||
DJANGO_SETTINGS_MODULE=dpdapisite.settings_prod \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
SENTRY_RELEASE=$SENTRY_RELEASE
|
||||
USER mxapi
|
||||
EXPOSE 8000
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--no-control-socket", "--access-logfile", "-", "dpdapisite.wsgi"]
|
||||
@@ -1,26 +1,23 @@
|
||||
# Install
|
||||
apt install python-dev libmysqlclient-dev python-virtualenv
|
||||
virtualenv venv
|
||||
. venv/bin/activate
|
||||
pip install -r requirements
|
||||
# django-postfix-dovecot-api
|
||||
|
||||
# Deploy
|
||||
First you need to have your public key in /home/gitdeploy/.ssh/authorized_keys on the server.
|
||||
REST API for managing the virtual domains, users and aliases that Postfix and Dovecot read from the database.
|
||||
Used by [mailadmin](https://git.neuf.no/edb/mailadmin).
|
||||
|
||||
Then, to deploy via login.neuf.no, add the following to your personal ~/.ssh/config
|
||||
## Development
|
||||
|
||||
Host lynx.neuf.no
|
||||
User gitdeploy
|
||||
ForwardAgent yes
|
||||
ProxyCommand ssh -W %h:%p login.neuf.no -l USERNAME
|
||||
```sh
|
||||
mise install
|
||||
uv sync
|
||||
prek install
|
||||
uv run python manage.py migrate
|
||||
uv run python manage.py runserver
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
Then you can do
|
||||
API docs are served at `/api/docs`. Override settings (database, secret key, Sentry) in
|
||||
`dpdapisite/local_settings.py`.
|
||||
|
||||
fab deploy
|
||||
|
||||
# TODO
|
||||
- Domain Aliases
|
||||
## Deployment
|
||||
|
||||
# Inspiration:
|
||||
- http://modoboa.readthedocs.org/
|
||||
- https://github.com/lgunsch/django-vmail
|
||||
CI builds an image for each push to `main` and deploys it to lynx while the repository variable `DEPLOY_LYNX` is
|
||||
`true`. The image reads its settings from the environment (`dpdapisite/settings_prod.py`).
|
||||
+4
-4
@@ -5,9 +5,9 @@ from dpdapi.models import Alias, Domain, User
|
||||
|
||||
@admin.register(Alias)
|
||||
class AliasAdmin(admin.ModelAdmin):
|
||||
list_display = ['source', 'destination', 'active', 'domain']
|
||||
list_filter = ['domain']
|
||||
search_fields = ['source', 'destination']
|
||||
list_display = ("source", "destination", "active", "domain")
|
||||
list_filter = ("domain",)
|
||||
search_fields = ("source", "destination")
|
||||
|
||||
|
||||
@admin.register(Domain)
|
||||
@@ -17,4 +17,4 @@ class DomainAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
pass
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
import logging
|
||||
import operator
|
||||
from datetime import datetime
|
||||
from functools import reduce
|
||||
from typing import Annotated
|
||||
|
||||
from django.contrib.auth import authenticate
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.core.validators import validate_email
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from ninja import NinjaAPI, PatchDict, Query, Schema, Status
|
||||
from ninja.errors import ValidationError
|
||||
from ninja.security import HttpBasicAuth, django_auth
|
||||
from passlib.hash import sha512_crypt
|
||||
from pydantic import AfterValidator, PlainSerializer, StringConstraints
|
||||
|
||||
from dpdapi.models import Alias, Domain, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BasicAuth(HttpBasicAuth):
|
||||
def authenticate(self, request, username, password):
|
||||
return authenticate(request, username=username, password=password)
|
||||
|
||||
|
||||
api = NinjaAPI(title="Postfix/Dovecot API", auth=[BasicAuth(), django_auth], urls_namespace="api")
|
||||
|
||||
|
||||
@api.exception_handler(ValidationError)
|
||||
def validation_error(request, exc):
|
||||
return api.create_response(request, {"detail": exc.errors}, status=400)
|
||||
|
||||
|
||||
def _email(value: str) -> str:
|
||||
try:
|
||||
validate_email(value)
|
||||
except DjangoValidationError as e:
|
||||
raise ValueError("Enter a valid email address.") from e
|
||||
return value.lower()
|
||||
|
||||
|
||||
Email = Annotated[str, StringConstraints(strip_whitespace=True, max_length=254), AfterValidator(_email)]
|
||||
Name = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=254)]
|
||||
Password = Annotated[str, StringConstraints(min_length=1, max_length=512)]
|
||||
# Rendered in the site's time zone, as Django REST framework did.
|
||||
LocalDateTime = Annotated[datetime, PlainSerializer(lambda v: timezone.localtime(v).isoformat())]
|
||||
|
||||
|
||||
def invalid(field: str, msg: str) -> ValidationError:
|
||||
return ValidationError([{"loc": ["body", "payload", field], "msg": msg, "type": "value_error"}])
|
||||
|
||||
|
||||
def check_domains(domain_ids: list[int]):
|
||||
"""Reject domain ids that don't exist."""
|
||||
if missing := set(domain_ids) - set(Domain.objects.filter(pk__in=domain_ids).values_list("pk", flat=True)):
|
||||
raise invalid("domain", f"Invalid pk {sorted(missing)} - object does not exist.")
|
||||
|
||||
|
||||
def check_unique(instance: models.Model, field: str, value: str):
|
||||
qs = type(instance).objects.filter(**{field: value}).exclude(pk=instance.pk)
|
||||
if qs.exists():
|
||||
raise invalid(field, f"{instance._meta.verbose_name} with this {field} already exists.")
|
||||
|
||||
|
||||
def not_null(data: dict) -> dict:
|
||||
"""PatchDict makes every field optional *and* nullable; only the former is wanted."""
|
||||
for field, value in data.items():
|
||||
if value is None:
|
||||
raise invalid(field, "This field may not be null.")
|
||||
return data
|
||||
|
||||
|
||||
class Timestamped(Schema):
|
||||
id: int
|
||||
created: LocalDateTime
|
||||
updated: LocalDateTime
|
||||
|
||||
|
||||
# Aliases
|
||||
|
||||
|
||||
class AliasKey(Schema):
|
||||
source: Email
|
||||
destination: Email
|
||||
domain: int
|
||||
|
||||
|
||||
class AliasIn(AliasKey):
|
||||
active: bool = True
|
||||
|
||||
|
||||
class AliasOut(Timestamped):
|
||||
source: str
|
||||
destination: str
|
||||
domain: int
|
||||
active: bool
|
||||
|
||||
@staticmethod
|
||||
def resolve_domain(obj):
|
||||
return obj.domain_id
|
||||
|
||||
|
||||
class AliasFilters(Schema):
|
||||
domain: int | None = None
|
||||
domain__name: str | None = None
|
||||
source__regex: str | None = None
|
||||
source__iregex: str | None = None
|
||||
destination__regex: str | None = None
|
||||
destination__iregex: str | None = None
|
||||
|
||||
|
||||
class Result(Schema):
|
||||
result: str
|
||||
|
||||
|
||||
@api.get("/aliases/", response=list[AliasOut], tags=["aliases"])
|
||||
def list_aliases(request, filters: Query[AliasFilters]):
|
||||
return Alias.objects.filter(**filters.model_dump(exclude_none=True))
|
||||
|
||||
|
||||
@api.post("/aliases/create_bulk/", response={201: list[AliasOut]}, tags=["aliases"])
|
||||
@transaction.atomic
|
||||
def create_aliases(request, payload: list[AliasIn]):
|
||||
"""Create aliases, returning existing ones instead of failing on duplicates."""
|
||||
check_domains([a.domain for a in payload])
|
||||
aliases = [
|
||||
Alias.objects.get_or_create(
|
||||
source=a.source, destination=a.destination, domain_id=a.domain, defaults={"active": a.active}
|
||||
)[0]
|
||||
for a in payload
|
||||
]
|
||||
return Status(201, aliases)
|
||||
|
||||
|
||||
@api.delete("/aliases/delete_bulk/", response={200: Result, 204: None}, tags=["aliases"])
|
||||
@transaction.atomic
|
||||
def delete_aliases(request, payload: list[AliasKey]):
|
||||
"""Delete aliases, reporting the ones that didn't exist."""
|
||||
check_domains([a.domain for a in payload])
|
||||
keys = {(a.source, a.destination, a.domain) for a in payload}
|
||||
query = reduce(operator.or_, (Q(source=s, destination=d, domain_id=dom) for s, d, dom in keys), Q(pk__in=[]))
|
||||
aliases = Alias.objects.filter(query)
|
||||
deleted = set(aliases.values_list("source", "destination", "domain"))
|
||||
aliases.delete()
|
||||
logger.debug("Deleted: %s", ",".join(f"{s}={d}" for s, d, _ in sorted(deleted)))
|
||||
|
||||
if missing := keys - deleted:
|
||||
formatted = ", ".join(f"{s}={d}" for s, d, _ in sorted(missing))
|
||||
result = f"Aliases '{formatted}' could not be found and were not deleted."
|
||||
logger.info(result)
|
||||
return Status(200, {"result": result})
|
||||
return Status(204, None)
|
||||
|
||||
|
||||
# Domains
|
||||
|
||||
|
||||
class DomainIn(Schema):
|
||||
name: Name
|
||||
|
||||
|
||||
class DomainOut(Timestamped):
|
||||
name: str
|
||||
|
||||
|
||||
class DomainFilters(Schema):
|
||||
name: str | None = None
|
||||
|
||||
|
||||
def save_domain(domain: Domain, data: dict) -> Domain:
|
||||
if "name" in data:
|
||||
domain.name = data["name"].lower()
|
||||
check_unique(domain, "name", domain.name)
|
||||
domain.save()
|
||||
return domain
|
||||
|
||||
|
||||
@api.get("/domains/", response=list[DomainOut], tags=["domains"])
|
||||
def list_domains(request, filters: Query[DomainFilters]):
|
||||
return Domain.objects.filter(**filters.model_dump(exclude_none=True))
|
||||
|
||||
|
||||
@api.post("/domains/", response={201: DomainOut}, tags=["domains"])
|
||||
def create_domain(request, payload: DomainIn):
|
||||
return Status(201, save_domain(Domain(), payload.model_dump()))
|
||||
|
||||
|
||||
@api.get("/domains/{int:pk}/", response=DomainOut, tags=["domains"])
|
||||
def get_domain(request, pk: int):
|
||||
return get_object_or_404(Domain, pk=pk)
|
||||
|
||||
|
||||
@api.put("/domains/{int:pk}/", response=DomainOut, tags=["domains"])
|
||||
def update_domain(request, pk: int, payload: DomainIn):
|
||||
return save_domain(get_object_or_404(Domain, pk=pk), payload.model_dump())
|
||||
|
||||
|
||||
@api.patch("/domains/{int:pk}/", response=DomainOut, tags=["domains"])
|
||||
def partial_update_domain(request, pk: int, payload: PatchDict[DomainIn]):
|
||||
return save_domain(get_object_or_404(Domain, pk=pk), not_null(payload))
|
||||
|
||||
|
||||
@api.delete("/domains/{int:pk}/", response={204: None}, tags=["domains"])
|
||||
def delete_domain(request, pk: int):
|
||||
get_object_or_404(Domain, pk=pk).delete()
|
||||
return Status(204, None)
|
||||
|
||||
|
||||
# Users
|
||||
|
||||
|
||||
class UserIn(Schema):
|
||||
email: Email
|
||||
password: Password
|
||||
domain: int
|
||||
|
||||
|
||||
class UserOut(Timestamped):
|
||||
email: str
|
||||
domain: int
|
||||
|
||||
@staticmethod
|
||||
def resolve_domain(obj):
|
||||
return obj.domain_id
|
||||
|
||||
|
||||
def save_user(user: User, data: dict) -> User:
|
||||
if "email" in data:
|
||||
user.email = data["email"]
|
||||
check_unique(user, "email", user.email)
|
||||
if "domain" in data:
|
||||
check_domains([data["domain"]])
|
||||
user.domain_id = data["domain"]
|
||||
if "password" in data:
|
||||
user.password = sha512_crypt.hash(data["password"])
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
@api.get("/users/", response=list[UserOut], tags=["users"])
|
||||
def list_users(request):
|
||||
return User.objects.all()
|
||||
|
||||
|
||||
@api.post("/users/", response={201: UserOut}, tags=["users"])
|
||||
def create_user(request, payload: UserIn):
|
||||
return Status(201, save_user(User(), payload.model_dump()))
|
||||
|
||||
|
||||
@api.get("/users/{int:pk}/", response=UserOut, tags=["users"])
|
||||
def get_user(request, pk: int):
|
||||
return get_object_or_404(User, pk=pk)
|
||||
|
||||
|
||||
@api.put("/users/{int:pk}/", response=UserOut, tags=["users"])
|
||||
def update_user(request, pk: int, payload: UserIn):
|
||||
return save_user(get_object_or_404(User, pk=pk), payload.model_dump())
|
||||
|
||||
|
||||
@api.patch("/users/{int:pk}/", response=UserOut, tags=["users"])
|
||||
def partial_update_user(request, pk: int, payload: PatchDict[UserIn]):
|
||||
return save_user(get_object_or_404(User, pk=pk), not_null(payload))
|
||||
|
||||
|
||||
@api.delete("/users/{int:pk}/", response={204: None}, tags=["users"])
|
||||
def delete_user(request, pk: int):
|
||||
get_object_or_404(User, pk=pk).delete()
|
||||
return Status(204, None)
|
||||
@@ -1,40 +0,0 @@
|
||||
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')
|
||||
@@ -1,17 +0,0 @@
|
||||
import django_filters
|
||||
|
||||
from dpdapi.models import Alias
|
||||
from rest_framework.filters import BaseFilterBackend
|
||||
|
||||
|
||||
class AliasRegexFilter(django_filters.FilterSet):
|
||||
# Ref: https://docs.djangoproject.com/en/1.7/ref/models/querysets/#iregex
|
||||
source_regex = django_filters.CharFilter(name="source", lookup_type='iregex')
|
||||
|
||||
class Meta:
|
||||
model = Alias
|
||||
|
||||
|
||||
class AliasRegexFilterBackend(BaseFilterBackend):
|
||||
def filter_queryset(self, request, queryset, view):
|
||||
return AliasRegexFilter(request.query_params, queryset=queryset).qs
|
||||
@@ -1,60 +1,60 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.utils import IntegrityError
|
||||
import os
|
||||
from django.db import IntegrityError, transaction
|
||||
|
||||
from dpdapi.models import Alias, Domain
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Import aliases for a single domain at a time, which is the domain of the first source address.'
|
||||
help = "Import aliases for a single domain at a time, which is the domain of the first source address."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) == 1:
|
||||
if os.path.exists(args[0]):
|
||||
self.import_file(args[0])
|
||||
else:
|
||||
self.stdout.write("{} does not exist".format(args[0]))
|
||||
else:
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("filename", nargs="?")
|
||||
|
||||
def handle(self, *args, filename=None, **options):
|
||||
if filename is None:
|
||||
self.stdout.write("Missing argument")
|
||||
elif not Path(filename).exists():
|
||||
self.stdout.write(f"{filename} does not exist")
|
||||
else:
|
||||
self.import_file(filename)
|
||||
|
||||
def import_file(self, filename):
|
||||
new_aliases = []
|
||||
counts = {'imported': 0, 'existing': 0}
|
||||
# Open file
|
||||
with open(filename) as f:
|
||||
# Parse
|
||||
for l in f:
|
||||
if l.startswith('*: '):
|
||||
counts = {"imported": 0, "existing": 0}
|
||||
with Path(filename).open() as f:
|
||||
for line in f:
|
||||
if line.startswith("*: "):
|
||||
continue # Skip catch all
|
||||
|
||||
source, destinations = l.strip().split(': ')
|
||||
for destination in destinations.split(','):
|
||||
if destination.startswith('"|') or '-admin@' in destination:
|
||||
source, destinations = line.strip().split(": ")
|
||||
for destination in destinations.split(","):
|
||||
if destination.startswith('"|') or "-admin@" in destination:
|
||||
continue # Skip mailman-aliases
|
||||
new_aliases.append(Alias(source=source, destination=destination))
|
||||
|
||||
if len(new_aliases) == 0:
|
||||
self.stdout.write('No aliases found')
|
||||
if not new_aliases:
|
||||
self.stdout.write("No aliases found")
|
||||
return
|
||||
|
||||
# Lookup domain
|
||||
domain_name = new_aliases[0].source.split('@')[1] # Assumes that every alias is in the same domain
|
||||
d, created = Domain.objects.get_or_create(name=domain_name)
|
||||
# Assumes that every alias is in the same domain
|
||||
domain_name = new_aliases[0].source.split("@")[1]
|
||||
domain, created = Domain.objects.get_or_create(name=domain_name)
|
||||
if created:
|
||||
self.stdout.write('Created domain {}.'.format(d.name))
|
||||
self.stdout.write(f"Created domain {domain.name}.")
|
||||
|
||||
# Try to create, log if exists. Slow but works
|
||||
for a in new_aliases:
|
||||
a.domain = d
|
||||
for alias in new_aliases:
|
||||
alias.domain = domain
|
||||
try:
|
||||
a.save()
|
||||
counts['imported'] += 1 # Woop!
|
||||
with transaction.atomic():
|
||||
alias.save()
|
||||
counts["imported"] += 1
|
||||
except IntegrityError:
|
||||
counts['existing'] += 1 # Exists
|
||||
self.stdout.write('Could not import \'{}={}\', already exists.'.format(a.source, a.destination))
|
||||
counts["existing"] += 1
|
||||
self.stdout.write(f"Could not import '{alias.source}={alias.destination}', already exists.")
|
||||
|
||||
# Print num imported, existing, total
|
||||
self.stdout.write('Imported {}, skipped {} existing, total {}'.format(
|
||||
counts['imported'],
|
||||
counts['existing'],
|
||||
len(new_aliases)))
|
||||
self.stdout.write(
|
||||
f"Imported {counts['imported']}, skipped {counts['existing']} existing, total {len(new_aliases)}"
|
||||
)
|
||||
@@ -46,7 +46,7 @@ class Migration(migrations.Migration):
|
||||
('updated', models.DateTimeField(auto_now=True)),
|
||||
('email', models.EmailField(unique=True, max_length=254)),
|
||||
('password', models.CharField(max_length=109)),
|
||||
('domain', models.ForeignKey(to='dpdapi.Domain')),
|
||||
('domain', models.ForeignKey(to='dpdapi.Domain', on_delete=models.CASCADE)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
@@ -56,7 +56,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='alias',
|
||||
name='domain',
|
||||
field=models.ForeignKey(to='dpdapi.Domain'),
|
||||
field=models.ForeignKey(to='dpdapi.Domain', on_delete=models.CASCADE),
|
||||
preserve_default=True,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.16 on 2020-09-09 21:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dpdapi', '0002_auto_20150118_1638'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='password',
|
||||
field=models.CharField(max_length=512),
|
||||
),
|
||||
]
|
||||
+14
-16
@@ -1,4 +1,3 @@
|
||||
from __future__ import unicode_literals
|
||||
from django.db import models
|
||||
|
||||
|
||||
@@ -13,45 +12,44 @@ class BaseModel(models.Model):
|
||||
class Alias(BaseModel):
|
||||
source = models.EmailField(max_length=254)
|
||||
destination = models.EmailField(max_length=254)
|
||||
domain = models.ForeignKey('dpdapi.Domain')
|
||||
domain = models.ForeignKey("dpdapi.Domain", models.CASCADE)
|
||||
active = models.BooleanField(default=True)
|
||||
|
||||
def __unicode__(self):
|
||||
return "{}={}".format(self.source, self.destination)
|
||||
class Meta:
|
||||
verbose_name_plural = "aliases"
|
||||
unique_together = ("source", "destination", "domain")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.source}={self.destination}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.source = self.source.lower()
|
||||
self.destination = self.destination.lower()
|
||||
|
||||
super(Alias, self).save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = 'aliases'
|
||||
unique_together = ('source', 'destination', 'domain')
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Domain(BaseModel):
|
||||
name = models.CharField(max_length=254, unique=True)
|
||||
|
||||
def __unicode__(self):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.name = self.name.lower()
|
||||
|
||||
super(Domain, self).save(*args, **kwargs)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
email = models.EmailField(max_length=254, unique=True)
|
||||
password = models.CharField(max_length=109)
|
||||
domain = models.ForeignKey('dpdapi.Domain')
|
||||
password = models.CharField(max_length=512)
|
||||
domain = models.ForeignKey("dpdapi.Domain", models.CASCADE)
|
||||
|
||||
def __unicode__(self):
|
||||
def __str__(self):
|
||||
return self.email
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.email = self.email.lower()
|
||||
|
||||
super(User, self).save(*args, **kwargs)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
@@ -1,41 +0,0 @@
|
||||
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
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -1,10 +0,0 @@
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import AliasViewSet, DomainViewSet, UserViewSet
|
||||
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'aliases', AliasViewSet)
|
||||
router.register(r'domains', DomainViewSet)
|
||||
router.register(r'users', UserViewSet)
|
||||
urlpatterns = router.urls
|
||||
@@ -1,73 +0,0 @@
|
||||
from __future__ import unicode_literals
|
||||
import logging
|
||||
import operator
|
||||
from django.db.models import Q
|
||||
from rest_framework.decorators import list_route
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status, filters, viewsets
|
||||
|
||||
from dpdapi.filters import AliasRegexFilterBackend
|
||||
from dpdapi.models import Alias, Domain, User
|
||||
from dpdapi.serializers import AliasSerializer, AliasDeleteSerializer, DomainSerializer, UserSerializer
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AliasViewSet(viewsets.ModelViewSet):
|
||||
""" Aliases """
|
||||
queryset = Alias.objects.all()
|
||||
serializer_class = AliasSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
filter_backends = (filters.DjangoFilterBackend, AliasRegexFilterBackend)
|
||||
filter_fields = ('domain', 'domain__name',)
|
||||
|
||||
@list_route(methods=['post'])
|
||||
def create_bulk(self, request):
|
||||
serializer = AliasSerializer(data=request.data, many=True)
|
||||
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@list_route(methods=['delete'])
|
||||
def delete_bulk(self, request):
|
||||
serializer = AliasDeleteSerializer(data=request.data, many=True)
|
||||
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Ref: http://michelepasin.org/blog/2010/07/20/the-power-of-djangos-q-objects/
|
||||
q_list = map(lambda x: Q(**x), serializer.initial_data)
|
||||
aliases = Alias.objects.filter(reduce(operator.or_, q_list))
|
||||
|
||||
initial_set = set(map(lambda x: (x['source'], x['destination'], x['domain']), serializer.initial_data))
|
||||
deleted_set = set(aliases.values_list('source', 'destination', 'domain'))
|
||||
diff = initial_set.difference(deleted_set)
|
||||
logger.debug('Deleted: {}'.format(','.join(map(lambda x: '{0[0]}={0[1]}'.format(x), deleted_set))))
|
||||
aliases.delete()
|
||||
|
||||
if diff:
|
||||
diff_formatted = map(lambda x: '{0[0]}={0[1]}'.format(x), diff)
|
||||
res = "Aliases '{}' could not be found and were not deleted.".format(", ".join(diff_formatted))
|
||||
logger.info(res)
|
||||
return Response({"result": res}, status=status.HTTP_200_OK)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class DomainViewSet(viewsets.ModelViewSet):
|
||||
""" Domains """
|
||||
queryset = Domain.objects.all()
|
||||
serializer_class = DomainSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
filter_backends = (filters.DjangoFilterBackend,)
|
||||
filter_fields = ('name',)
|
||||
|
||||
|
||||
class UserViewSet(viewsets.ModelViewSet):
|
||||
""" Users """
|
||||
queryset = User.objects.all()
|
||||
serializer_class = UserSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
+88
-59
@@ -1,98 +1,127 @@
|
||||
"""
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/1.7/topics/settings/
|
||||
https://docs.djangoproject.com/en/stable/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/1.7/ref/settings/
|
||||
https://docs.djangoproject.com/en/stable/ref/settings/
|
||||
"""
|
||||
|
||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
import os
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/
|
||||
# See https://docs.djangoproject.com/en/stable/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = 'hlaf#yma(8s!cy-bf5e6kc&_5n_l1h!34c(j32-b3v8o7pg$5n'
|
||||
SECRET_KEY = "django-insecure-hlaf#yma(8s!cy-bf5e6kc&_5n_l1h!34c(j32-b3v8o7pg$5n" # noqa: S105
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
TEMPLATE_DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = []
|
||||
|
||||
|
||||
# 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 = (
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
)
|
||||
INSTALLED_APPS += [
|
||||
"dpdapi",
|
||||
]
|
||||
|
||||
INSTALLED_APPS += (
|
||||
'rest_framework',
|
||||
'dpdapi',
|
||||
)
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
]
|
||||
|
||||
MIDDLEWARE_CLASSES = (
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
)
|
||||
ROOT_URLCONF = "dpdapisite.urls"
|
||||
|
||||
ROOT_URLCONF = 'dpdapisite.urls'
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [
|
||||
# insert your TEMPLATE_DIRS here
|
||||
],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'dpdapisite.wsgi.application'
|
||||
WSGI_APPLICATION = "dpdapisite.wsgi.application"
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/1.7/ref/settings/#databases
|
||||
# https://docs.djangoproject.com/en/stable/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/1.7/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
TIME_ZONE = 'Europe/Oslo'
|
||||
|
||||
# https://docs.djangoproject.com/en/stable/topics/i18n/
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "Europe/Oslo"
|
||||
USE_I18N = True
|
||||
|
||||
USE_L10N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/1.7/howto/static-files/
|
||||
# https://docs.djangoproject.com/en/stable/howto/static-files/
|
||||
STATIC_URL = "/static/"
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework.authentication.BasicAuthentication',
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
)
|
||||
# Postfix and Dovecot read these tables directly; keep the existing integer id columns.
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
STATIC_ROOT = BASE_DIR / "static"
|
||||
STORAGES = {
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
|
||||
}
|
||||
|
||||
try:
|
||||
from local_settings import *
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"root": {
|
||||
"level": "INFO",
|
||||
"handlers": ["console"],
|
||||
},
|
||||
"formatters": {
|
||||
"verbose": {"format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s"},
|
||||
},
|
||||
"handlers": {"console": {"level": "DEBUG", "class": "logging.StreamHandler", "formatter": "verbose"}},
|
||||
"loggers": {
|
||||
"django.db.backends": {
|
||||
"level": "ERROR",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
with suppress(ImportError):
|
||||
from .local_settings import * # noqa: F403
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Settings for the container image, from environment variables."""
|
||||
|
||||
import os
|
||||
|
||||
from dpdapisite.settings import * # noqa: F403
|
||||
|
||||
DEBUG = False
|
||||
SECRET_KEY = os.environ["SECRET_KEY"]
|
||||
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "").split(",")
|
||||
CSRF_TRUSTED_ORIGINS = [f"https://{host}" for host in ALLOWED_HOSTS if host]
|
||||
|
||||
# A DB_HOST starting with / is a unix socket
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.mysql",
|
||||
"NAME": os.getenv("DB_NAME", "prod_mail"),
|
||||
"USER": os.getenv("DB_USER", "mailserver"),
|
||||
"PASSWORD": os.environ["DB_PASSWORD"],
|
||||
"HOST": os.getenv("DB_HOST", "/run/mysqld/mysqld.sock"),
|
||||
"CONN_MAX_AGE": 60,
|
||||
"CONN_HEALTH_CHECKS": True,
|
||||
},
|
||||
}
|
||||
|
||||
# TLS ends at the host's nginx
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
SECURE_SSL_REDIRECT = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
|
||||
if dsn := os.getenv("SENTRY_DSN"):
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=dsn,
|
||||
environment=os.getenv("SENTRY_ENVIRONMENT", "production"),
|
||||
release=os.getenv("SENTRY_RELEASE", "N/A"),
|
||||
integrations=[DjangoIntegration()],
|
||||
send_default_pii=False,
|
||||
traces_sample_rate=0,
|
||||
send_client_reports=False,
|
||||
auto_session_tracking=False,
|
||||
)
|
||||
+8
-8
@@ -1,11 +1,11 @@
|
||||
from django.conf.urls import patterns, include, url
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
urlpatterns = patterns(
|
||||
'',
|
||||
url(r'^$', RedirectView.as_view(pattern_name='api-root')),
|
||||
url(r'^api/', include('dpdapi.urls')),
|
||||
url(r'^admin/', include(admin.site.urls)),
|
||||
url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework'))
|
||||
)
|
||||
from dpdapi.api import api
|
||||
|
||||
urlpatterns = [
|
||||
path("", RedirectView.as_view(pattern_name="api:openapi-view")),
|
||||
path("api/", api.urls),
|
||||
path("admin/", admin.site.urls),
|
||||
]
|
||||
+3
-10
@@ -1,14 +1,7 @@
|
||||
"""
|
||||
WSGI config for pfrsite project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/1.7/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dpdapisite.settings")
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dpdapisite.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
Vendored
-63
@@ -1,63 +0,0 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from contextlib import contextmanager as _contextmanager
|
||||
from fabric.api import run, sudo, env, cd, prefix, lcd
|
||||
from fabric.api import local as lrun
|
||||
|
||||
env.use_ssh_config = True
|
||||
env.hosts = ['lynx.neuf.no']
|
||||
env.project_path = '/var/www/neuf.no/django-postfix-dovecot-api'
|
||||
env.user = 'gitdeploy'
|
||||
env.activate = 'source {}/venv/bin/activate'.format(env.project_path)
|
||||
|
||||
|
||||
@_contextmanager
|
||||
def virtualenv():
|
||||
with cd(env.project_path):
|
||||
with prefix(env.activate):
|
||||
yield
|
||||
|
||||
|
||||
def __git_validate_clean():
|
||||
with lcd(env.project_path):
|
||||
if lrun('git diff-files', capture=True) != '':
|
||||
logging.error("Your git-tree isn't clean! Commit your changes before deploying.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def test():
|
||||
lrun("python manage.py test dpdapi")
|
||||
|
||||
|
||||
def commit():
|
||||
lrun("git add -p && git commit")
|
||||
|
||||
|
||||
def push():
|
||||
lrun("git push")
|
||||
|
||||
|
||||
def prepare_deploy():
|
||||
test()
|
||||
commit()
|
||||
push()
|
||||
|
||||
|
||||
def gitstatus():
|
||||
# __git_validate_clean()
|
||||
with virtualenv():
|
||||
# Get source
|
||||
run('git branch -v')
|
||||
|
||||
|
||||
def deploy():
|
||||
# __git_validate_clean()
|
||||
with virtualenv():
|
||||
run('git pull') # Get source
|
||||
run('pip install -r requirements.txt') # install deps in virtualenv
|
||||
run('umask 022; python manage.py collectstatic --noinput') # Collect static
|
||||
run('python manage.py migrate') # Run DB migrations
|
||||
|
||||
# Reload gunicorn
|
||||
sudo('/etc/init.d/gunicorn reload', shell=False)
|
||||
@@ -0,0 +1,7 @@
|
||||
[tools]
|
||||
python = "3.14"
|
||||
uv = "latest"
|
||||
prek = "latest"
|
||||
|
||||
[env]
|
||||
_.python.venv = { path = ".venv", create = false }
|
||||
@@ -0,0 +1,39 @@
|
||||
#:schema https://www.schemastore.org/prek.json
|
||||
# Git hooks, run by prek (https://prek.j178.dev). `prek install` once per clone; CI runs
|
||||
# them all (.gitea/workflows/ci.yaml).
|
||||
|
||||
[[repos]]
|
||||
repo = "builtin"
|
||||
hooks = [
|
||||
{ id = "trailing-whitespace" },
|
||||
{ id = "end-of-file-fixer" },
|
||||
{ id = "mixed-line-ending", args = ["--fix", "lf"] },
|
||||
{ id = "fix-byte-order-marker" },
|
||||
{ id = "check-added-large-files" },
|
||||
{ id = "check-case-conflict" },
|
||||
{ id = "check-merge-conflict" },
|
||||
{ id = "check-executables-have-shebangs" },
|
||||
{ id = "check-shebang-scripts-are-executable" },
|
||||
{ id = "check-symlinks" },
|
||||
{ id = "check-toml" },
|
||||
{ id = "check-yaml" },
|
||||
{ id = "detect-private-key" },
|
||||
]
|
||||
|
||||
[[repos]]
|
||||
repo = "https://github.com/astral-sh/ruff-pre-commit"
|
||||
rev = "v0.17.3" # keep in sync with ruff in uv.lock
|
||||
hooks = [
|
||||
{ id = "ruff-check", args = ["--fix"] },
|
||||
{ id = "ruff-format" },
|
||||
]
|
||||
|
||||
[[repos]]
|
||||
repo = "https://github.com/adamchainz/django-upgrade"
|
||||
rev = "1.33.0"
|
||||
hooks = [{ id = "django-upgrade", args = ["--target-version", "5.2"] }]
|
||||
|
||||
[[repos]]
|
||||
repo = "https://github.com/astral-sh/uv-pre-commit"
|
||||
rev = "0.13.6"
|
||||
hooks = [{ id = "uv-lock" }] # uv.lock stays in sync with pyproject.toml
|
||||
@@ -0,0 +1,59 @@
|
||||
[project]
|
||||
name = "django-postfix-dovecot-api"
|
||||
version = "0.1.0"
|
||||
description = "REST API for managing Postfix/Dovecot virtual domains, users and aliases"
|
||||
readme = "README.md"
|
||||
requires-python = "~=3.14.0"
|
||||
dependencies = [
|
||||
"django>=5.2,<6",
|
||||
"django-extensions>=4.1",
|
||||
"django-ninja>=1.7.3",
|
||||
"libpass>=1.9.4",
|
||||
"sentry-sdk>=2.73.0",
|
||||
"whitenoise>=6.12.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=9.2.0",
|
||||
"pytest-cov>=7.1.0",
|
||||
"pytest-django>=4.14.0",
|
||||
"ruff>=0.17.3",
|
||||
]
|
||||
prod = [
|
||||
"gunicorn>=26.2.0",
|
||||
"mysqlclient>=2.3.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE = "dpdapisite.settings"
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
filterwarnings = ["error"]
|
||||
addopts = "--cov --cov-report=term-missing"
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["dpdapi", "dpdapisite"]
|
||||
omit = ["*/migrations/*", "dpdapisite/settings.py", "dpdapisite/settings_prod.py"]
|
||||
branch = true
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 100
|
||||
show_missing = true
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py314"
|
||||
extend-exclude = ["*/migrations/*"]
|
||||
force-exclude = true
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", "W", "F", "I", "UP", "B", "A", "C4", "DJ", "S", "SIM", "PT", "PTH", "RET", "RUF", "N", "LOG", "G",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = ["S101", "S105", "S106"]
|
||||
@@ -1,7 +0,0 @@
|
||||
Django>=1.7,<1.8
|
||||
django-extensions==1.4.9
|
||||
django-filter==0.9.1
|
||||
djangorestframework>=3.0,<3.1
|
||||
passlib==1.6.2
|
||||
mysqlclient==1.3.4
|
||||
Fabric==1.10.1
|
||||
Whitespace-only changes.
@@ -0,0 +1,34 @@
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def static_files(settings, tmp_path):
|
||||
"""The manifest storage needs collectstatic, which tests don't run."""
|
||||
settings.STATIC_ROOT = tmp_path / "static"
|
||||
settings.STATIC_ROOT.mkdir()
|
||||
settings.STORAGES = {
|
||||
**settings.STORAGES,
|
||||
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin(django_user_model):
|
||||
return django_user_model.objects.create_superuser("admin", "admin@example.com", "secret")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api(client, admin):
|
||||
"""A Django test client authenticated with HTTP Basic auth."""
|
||||
token = base64.b64encode(b"admin:secret").decode()
|
||||
client.defaults["HTTP_AUTHORIZATION"] = f"Basic {token}"
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def domain(db):
|
||||
from dpdapi.models import Domain
|
||||
|
||||
return Domain.objects.create(name="example.com")
|
||||
@@ -0,0 +1,160 @@
|
||||
import pytest
|
||||
|
||||
from dpdapi.models import Alias, Domain
|
||||
|
||||
FIELDS = {"id", "created", "updated", "source", "destination", "domain", "active"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def aliases(domain):
|
||||
other = Domain.objects.create(name="other.org")
|
||||
return [
|
||||
Alias.objects.create(source="ops-alert@example.com", destination="ninja@example.com", domain=domain),
|
||||
Alias.objects.create(source="yolo-announce@example.com", destination="ninja@example.com", domain=domain),
|
||||
Alias.objects.create(source="Ops@Other.org", destination="Boss@Other.org", domain=other),
|
||||
]
|
||||
|
||||
|
||||
def sources(res):
|
||||
assert res.status_code == 200
|
||||
return sorted(a["source"] for a in res.json())
|
||||
|
||||
|
||||
def test_list(api, aliases):
|
||||
res = api.get("/api/aliases/")
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert len(body) == 3
|
||||
assert set(body[0]) == FIELDS
|
||||
first = next(a for a in body if a["id"] == aliases[0].pk)
|
||||
assert first["source"] == "ops-alert@example.com"
|
||||
assert first["destination"] == "ninja@example.com"
|
||||
assert first["domain"] == aliases[0].domain_id
|
||||
assert first["active"] is True
|
||||
|
||||
|
||||
def test_addresses_are_lowercased(aliases):
|
||||
assert aliases[2].source == "ops@other.org"
|
||||
assert aliases[2].destination == "boss@other.org"
|
||||
assert str(aliases[2]) == "ops@other.org=boss@other.org"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("params", "expected"),
|
||||
[
|
||||
({"domain__name": "example.com"}, ["ops-alert@example.com", "yolo-announce@example.com"]),
|
||||
({"domain__name": "nope.com"}, []),
|
||||
({"source__regex": "^ops-|^ops@"}, ["ops-alert@example.com", "ops@other.org"]),
|
||||
({"source__regex": "^OPS"}, []),
|
||||
({"source__iregex": "^OPS"}, ["ops-alert@example.com", "ops@other.org"]),
|
||||
({"destination__regex": "^boss"}, ["ops@other.org"]),
|
||||
({"destination__iregex": "^NINJA"}, ["ops-alert@example.com", "yolo-announce@example.com"]),
|
||||
({"domain__name": "example.com", "source__regex": "^yolo"}, ["yolo-announce@example.com"]),
|
||||
],
|
||||
)
|
||||
def test_filters(api, aliases, params, expected):
|
||||
assert sources(api.get("/api/aliases/", params)) == expected
|
||||
|
||||
|
||||
def test_filter_by_domain_id(api, aliases):
|
||||
res = api.get("/api/aliases/", {"domain": aliases[2].domain_id})
|
||||
assert sources(res) == ["ops@other.org"]
|
||||
|
||||
|
||||
def test_create_bulk(api, domain):
|
||||
data = [
|
||||
{"source": "A@example.com", "destination": "b@example.com", "domain": domain.pk},
|
||||
{"source": "c@example.com", "destination": "d@example.com", "domain": domain.pk, "active": False},
|
||||
]
|
||||
res = api.post("/api/aliases/create_bulk/", data, content_type="application/json")
|
||||
assert res.status_code == 201
|
||||
body = res.json()
|
||||
assert [(a["source"], a["destination"], a["active"]) for a in body] == [
|
||||
("a@example.com", "b@example.com", True),
|
||||
("c@example.com", "d@example.com", False),
|
||||
]
|
||||
assert all(set(a) == FIELDS for a in body)
|
||||
assert Alias.objects.count() == 2
|
||||
|
||||
|
||||
def test_create_bulk_accepts_existing(api, aliases):
|
||||
existing = aliases[0]
|
||||
data = [{"source": existing.source, "destination": existing.destination, "domain": existing.domain_id}]
|
||||
res = api.post("/api/aliases/create_bulk/", data, content_type="application/json")
|
||||
assert res.status_code == 201
|
||||
assert res.json()[0]["id"] == existing.pk
|
||||
assert Alias.objects.count() == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"item",
|
||||
[
|
||||
{"source": "not-an-email", "destination": "b@example.com"},
|
||||
{"source": "a@example.com"},
|
||||
{"source": "a@example.com", "destination": "b@example.com", "domain": 999},
|
||||
],
|
||||
)
|
||||
def test_create_bulk_invalid(api, domain, item):
|
||||
item.setdefault("domain", domain.pk)
|
||||
res = api.post("/api/aliases/create_bulk/", [item], content_type="application/json")
|
||||
assert res.status_code == 400
|
||||
assert Alias.objects.count() == 0
|
||||
|
||||
|
||||
def test_create_bulk_requires_list(api, domain):
|
||||
data = {"source": "a@example.com", "destination": "b@example.com", "domain": domain.pk}
|
||||
res = api.post("/api/aliases/create_bulk/", data, content_type="application/json")
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
def test_delete_bulk(api, aliases):
|
||||
data = [{"source": a.source, "destination": a.destination, "domain": a.domain_id} for a in aliases[:2]]
|
||||
res = api.delete("/api/aliases/delete_bulk/", data, content_type="application/json")
|
||||
assert res.status_code == 204
|
||||
assert list(Alias.objects.all()) == [aliases[2]]
|
||||
|
||||
|
||||
def test_delete_bulk_empty(api, aliases):
|
||||
res = api.delete("/api/aliases/delete_bulk/", [], content_type="application/json")
|
||||
assert res.status_code == 204
|
||||
assert Alias.objects.count() == 3
|
||||
|
||||
|
||||
def test_delete_bulk_reports_missing(api, aliases):
|
||||
data = [
|
||||
{"source": aliases[0].source, "destination": aliases[0].destination, "domain": aliases[0].domain_id},
|
||||
{"source": "ghost@example.com", "destination": "ninja@example.com", "domain": aliases[0].domain_id},
|
||||
]
|
||||
res = api.delete("/api/aliases/delete_bulk/", data, content_type="application/json")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {
|
||||
"result": "Aliases 'ghost@example.com=ninja@example.com' could not be found and were not deleted."
|
||||
}
|
||||
assert Alias.objects.count() == 2
|
||||
|
||||
|
||||
def test_delete_bulk_only_matches_exact_alias(api, aliases):
|
||||
"""Extra keys must not widen the match to other aliases."""
|
||||
data = [
|
||||
{
|
||||
"source": "ghost@example.com",
|
||||
"destination": "ninja@example.com",
|
||||
"domain": aliases[0].domain_id,
|
||||
"source__regex": ".*",
|
||||
}
|
||||
]
|
||||
res = api.delete("/api/aliases/delete_bulk/", data, content_type="application/json")
|
||||
assert res.status_code == 200
|
||||
assert Alias.objects.count() == 3
|
||||
|
||||
|
||||
def test_delete_bulk_invalid(api, aliases):
|
||||
res = api.delete("/api/aliases/delete_bulk/", [{"source": "x"}], content_type="application/json")
|
||||
assert res.status_code == 400
|
||||
assert Alias.objects.count() == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["post", "put", "patch"])
|
||||
def test_alias_detail_routes_do_not_exist(api, aliases, method):
|
||||
res = getattr(api, method)("/api/aliases/", {}, content_type="application/json")
|
||||
assert res.status_code == 405
|
||||
@@ -0,0 +1,48 @@
|
||||
import pytest
|
||||
|
||||
ENDPOINTS = [
|
||||
("get", "/api/aliases/"),
|
||||
("post", "/api/aliases/create_bulk/"),
|
||||
("delete", "/api/aliases/delete_bulk/"),
|
||||
("get", "/api/domains/"),
|
||||
("post", "/api/domains/"),
|
||||
("get", "/api/domains/1/"),
|
||||
("put", "/api/domains/1/"),
|
||||
("patch", "/api/domains/1/"),
|
||||
("delete", "/api/domains/1/"),
|
||||
("get", "/api/users/"),
|
||||
("post", "/api/users/"),
|
||||
("get", "/api/users/1/"),
|
||||
("put", "/api/users/1/"),
|
||||
("patch", "/api/users/1/"),
|
||||
("delete", "/api/users/1/"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(("method", "url"), ENDPOINTS)
|
||||
def test_anonymous_is_rejected(client, method, url):
|
||||
res = getattr(client, method)(url, content_type="application/json")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_wrong_password_is_rejected(client, admin):
|
||||
client.defaults["HTTP_AUTHORIZATION"] = "Basic YWRtaW46d3Jvbmc=" # admin:wrong
|
||||
assert client.get("/api/domains/").status_code == 401
|
||||
|
||||
|
||||
def test_basic_auth(api):
|
||||
assert api.get("/api/domains/").status_code == 200
|
||||
|
||||
|
||||
def test_session_auth(client, admin):
|
||||
client.force_login(admin)
|
||||
assert client.get("/api/domains/").status_code == 200
|
||||
|
||||
|
||||
def test_root_redirects_to_api(client, admin):
|
||||
client.force_login(admin)
|
||||
res = client.get("/", follow=True)
|
||||
assert res.redirect_chain
|
||||
assert res.status_code == 200
|
||||
@@ -0,0 +1,98 @@
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
|
||||
from dpdapi.models import Domain
|
||||
|
||||
FIELDS = {"id", "created", "updated", "name"}
|
||||
|
||||
|
||||
def test_list(api, domain):
|
||||
Domain.objects.create(name="other.org")
|
||||
res = api.get("/api/domains/")
|
||||
assert res.status_code == 200
|
||||
assert sorted(d["name"] for d in res.json()) == ["example.com", "other.org"]
|
||||
assert set(res.json()[0]) == FIELDS
|
||||
|
||||
|
||||
def test_filter_by_name(api, domain):
|
||||
Domain.objects.create(name="other.org")
|
||||
res = api.get("/api/domains/", {"name": "other.org"})
|
||||
assert [d["name"] for d in res.json()] == ["other.org"]
|
||||
|
||||
|
||||
def test_retrieve(api, domain):
|
||||
res = api.get(f"/api/domains/{domain.pk}/")
|
||||
assert res.status_code == 200
|
||||
assert res.json()["name"] == "example.com"
|
||||
assert res.json()["id"] == domain.pk
|
||||
|
||||
|
||||
def test_timestamps_are_local_iso8601(api, domain):
|
||||
body = api.get(f"/api/domains/{domain.pk}/").json()
|
||||
assert body["created"] == timezone.localtime(domain.created).isoformat()
|
||||
assert body["updated"] == timezone.localtime(domain.updated).isoformat()
|
||||
|
||||
|
||||
def test_retrieve_missing(api, db):
|
||||
assert api.get("/api/domains/999/").status_code == 404
|
||||
|
||||
|
||||
def test_create(api, db):
|
||||
res = api.post("/api/domains/", {"name": "New.ORG"}, content_type="application/json")
|
||||
assert res.status_code == 201
|
||||
assert res.json()["name"] == "new.org"
|
||||
assert set(res.json()) == FIELDS
|
||||
assert str(Domain.objects.get()) == "new.org"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data", [{}, {"name": ""}, {"name": "example.com"}, {"name": "Example.COM"}, {"name": "x" * 255}]
|
||||
)
|
||||
def test_create_invalid(api, domain, data):
|
||||
res = api.post("/api/domains/", data, content_type="application/json")
|
||||
assert res.status_code == 400
|
||||
assert Domain.objects.count() == 1
|
||||
|
||||
|
||||
def test_update(api, domain):
|
||||
res = api.put(f"/api/domains/{domain.pk}/", {"name": "Renamed.com"}, content_type="application/json")
|
||||
assert res.status_code == 200
|
||||
assert res.json()["name"] == "renamed.com"
|
||||
domain.refresh_from_db()
|
||||
assert domain.name == "renamed.com"
|
||||
|
||||
|
||||
def test_update_invalid(api, domain):
|
||||
res = api.put(f"/api/domains/{domain.pk}/", {}, content_type="application/json")
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
def test_partial_update(api, domain):
|
||||
res = api.patch(f"/api/domains/{domain.pk}/", {"name": "patched.com"}, content_type="application/json")
|
||||
assert res.status_code == 200
|
||||
assert res.json()["name"] == "patched.com"
|
||||
|
||||
|
||||
def test_partial_update_empty(api, domain):
|
||||
res = api.patch(f"/api/domains/{domain.pk}/", {}, content_type="application/json")
|
||||
assert res.status_code == 200
|
||||
assert res.json()["name"] == "example.com"
|
||||
|
||||
|
||||
def test_partial_update_null(api, domain):
|
||||
res = api.patch(f"/api/domains/{domain.pk}/", {"name": None}, content_type="application/json")
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
def test_update_missing(api, db):
|
||||
res = api.put("/api/domains/999/", {"name": "x.com"}, content_type="application/json")
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
def test_delete(api, domain):
|
||||
assert api.delete(f"/api/domains/{domain.pk}/").status_code == 204
|
||||
assert not Domain.objects.exists()
|
||||
|
||||
|
||||
def test_delete_missing(api, db):
|
||||
assert api.delete("/api/domains/999/").status_code == 404
|
||||
@@ -0,0 +1,61 @@
|
||||
from io import StringIO
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
|
||||
from dpdapi.models import Alias, Domain
|
||||
|
||||
|
||||
def run(*args):
|
||||
out = StringIO()
|
||||
call_command("import_valiases", *args, stdout=out)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_missing_argument():
|
||||
assert run() == "Missing argument\n"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_missing_file(tmp_path):
|
||||
path = tmp_path / "nope"
|
||||
assert run(str(path)) == f"{path} does not exist\n"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_no_aliases(tmp_path):
|
||||
path = tmp_path / "valiases"
|
||||
path.write_text('*: catchall@example.com\nlist: "|/usr/lib/mailman/mail/mailman post list"\n')
|
||||
assert run(str(path)) == "No aliases found\n"
|
||||
assert not Domain.objects.exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_import(tmp_path):
|
||||
path = tmp_path / "valiases"
|
||||
path.write_text(
|
||||
"*: catchall@example.com\n"
|
||||
"ops@example.com: a@example.com,b@example.com\n"
|
||||
'list@example.com: "|/usr/lib/mailman/mail/mailman post list",list-admin@example.com,c@example.com\n'
|
||||
"ops@example.com: a@example.com\n"
|
||||
)
|
||||
out = run(str(path))
|
||||
assert out == (
|
||||
"Created domain example.com.\n"
|
||||
"Could not import 'ops@example.com=a@example.com', already exists.\n"
|
||||
"Imported 3, skipped 1 existing, total 4\n"
|
||||
)
|
||||
assert sorted(Alias.objects.values_list("source", "destination")) == [
|
||||
("list@example.com", "c@example.com"),
|
||||
("ops@example.com", "a@example.com"),
|
||||
("ops@example.com", "b@example.com"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_import_into_existing_domain(tmp_path):
|
||||
Domain.objects.create(name="example.com")
|
||||
path = tmp_path / "valiases"
|
||||
path.write_text("ops@example.com: a@example.com\n")
|
||||
assert run(str(path)) == "Imported 1, skipped 0 existing, total 1\n"
|
||||
@@ -0,0 +1,83 @@
|
||||
"""The exact calls mailadmin (https://git.neuf.no/edb/mailadmin, see mailadmin/api.py) makes, and what it reads."""
|
||||
|
||||
import pytest
|
||||
|
||||
from dpdapi.models import Alias, Domain
|
||||
|
||||
# mailadmin's PrefixQueryset.as_regex() for the prefixes "ops" and "yolo"
|
||||
PREFIX_REGEX = "^ops-|^ops@|^yolo-|^yolo@"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def aliases(domain):
|
||||
other = Domain.objects.create(name="other.org")
|
||||
Alias.objects.create(source="ops-alert@example.com", destination="ninja@example.com", domain=domain)
|
||||
Alias.objects.create(source="yolo@example.com", destination="ninja@example.com", domain=domain)
|
||||
Alias.objects.create(source="nope-list@example.com", destination="ninja@example.com", domain=domain)
|
||||
Alias.objects.create(source="ops@other.org", destination="ninja@other.org", domain=other)
|
||||
|
||||
|
||||
def test_list_domains(api, domain):
|
||||
res = api.get("/api/domains/", {"name": "example.com"})
|
||||
assert res.status_code == 200
|
||||
assert [(d["id"], d["name"]) for d in res.json()] == [(domain.pk, "example.com")]
|
||||
|
||||
|
||||
def test_list_aliases_regex(api, aliases):
|
||||
res = api.get("/api/aliases/", {"domain__name": "example.com", "source__iregex": PREFIX_REGEX})
|
||||
assert res.status_code == 200
|
||||
assert sorted(a["source"] for a in res.json()) == ["ops-alert@example.com", "yolo@example.com"]
|
||||
assert all({"id", "source", "destination"} <= set(a) for a in res.json())
|
||||
|
||||
|
||||
def test_create_aliases(api, domain):
|
||||
data = [
|
||||
{"source": "ops-new@example.com", "destination": "a@example.com", "domain": domain.pk},
|
||||
{"source": "ops-new@example.com", "destination": "B@Example.com", "domain": domain.pk},
|
||||
]
|
||||
res = api.post("/api/aliases/create_bulk/", data, content_type="application/json")
|
||||
assert res.status_code == 201
|
||||
body = res.json()
|
||||
assert [(a["source"], a["destination"]) for a in body] == [
|
||||
("ops-new@example.com", "a@example.com"),
|
||||
("ops-new@example.com", "b@example.com"),
|
||||
]
|
||||
assert [a["id"] for a in body] == list(Alias.objects.order_by("pk").values_list("pk", flat=True))
|
||||
|
||||
|
||||
def test_create_aliases_case_insensitive_duplicate(api, aliases, domain):
|
||||
data = [{"source": "OPS-alert@example.com", "destination": "Ninja@example.com", "domain": domain.pk}]
|
||||
res = api.post("/api/aliases/create_bulk/", data, content_type="application/json")
|
||||
assert res.status_code == 201
|
||||
assert Alias.objects.count() == 4
|
||||
|
||||
|
||||
def test_delete_aliases(api, aliases, domain):
|
||||
data = [{"source": "ops-alert@example.com", "destination": "ninja@example.com", "domain": domain.pk}]
|
||||
res = api.delete("/api/aliases/delete_bulk/", data, content_type="application/json")
|
||||
assert res.status_code == 204
|
||||
assert res.content == b"" # mailadmin turns a non-JSON body into None
|
||||
|
||||
|
||||
def test_delete_aliases_partially_missing(api, aliases, domain):
|
||||
data = [
|
||||
{"source": "ops-alert@example.com", "destination": "ninja@example.com", "domain": domain.pk},
|
||||
{"source": "ops-alert@example.com", "destination": "ghost@example.com", "domain": domain.pk},
|
||||
]
|
||||
res = api.delete("/api/aliases/delete_bulk/", data, content_type="application/json")
|
||||
assert res.status_code == 200
|
||||
assert "ops-alert@example.com=ghost@example.com" in res.json()["result"]
|
||||
|
||||
|
||||
def test_errors_are_json(api, domain):
|
||||
data = [{"source": "bad", "destination": "a@example.com", "domain": domain.pk}]
|
||||
res = api.post("/api/aliases/create_bulk/", data, content_type="application/json")
|
||||
assert res.status_code == 400
|
||||
assert res.json()
|
||||
|
||||
|
||||
def test_bad_credentials_are_json(client, db):
|
||||
client.defaults["HTTP_AUTHORIZATION"] = "Basic YWRtaW46d3Jvbmc="
|
||||
res = client.get("/api/domains/")
|
||||
assert res.status_code == 401
|
||||
assert res.json()
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Postfix/Dovecot read these tables directly, so their names must never change."""
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from django.db import connection
|
||||
|
||||
SCHEMA = {
|
||||
"dpdapi_domain": {"id", "created", "updated", "name"},
|
||||
"dpdapi_alias": {"id", "created", "updated", "source", "destination", "active", "domain_id"},
|
||||
"dpdapi_user": {"id", "created", "updated", "email", "password", "domain_id"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(("table", "columns"), SCHEMA.items())
|
||||
def test_tables_and_columns(table, columns):
|
||||
with connection.cursor() as cursor:
|
||||
description = connection.introspection.get_table_description(cursor, table)
|
||||
assert {c.name for c in description} == columns
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_no_pending_migrations():
|
||||
call_command("makemigrations", "dpdapi", "--check", "--dry-run")
|
||||
@@ -0,0 +1,23 @@
|
||||
import pytest
|
||||
|
||||
from dpdapi.models import Alias, Domain, User
|
||||
|
||||
|
||||
def test_wsgi_application():
|
||||
from dpdapisite.wsgi import application
|
||||
|
||||
assert callable(application)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", [Alias, Domain, User])
|
||||
def test_admin_changelist(client, admin, domain, model):
|
||||
client.force_login(admin)
|
||||
meta = model._meta
|
||||
res = client.get(f"/admin/{meta.app_label}/{meta.model_name}/", {"q": "example"})
|
||||
assert res.status_code == 200
|
||||
|
||||
|
||||
def test_sentry_disabled_in_tests():
|
||||
import sentry_sdk
|
||||
|
||||
assert not sentry_sdk.get_client().is_active()
|
||||
@@ -0,0 +1,107 @@
|
||||
import pytest
|
||||
from passlib.hash import sha512_crypt
|
||||
|
||||
from dpdapi.models import Domain, User
|
||||
|
||||
FIELDS = {"id", "created", "updated", "email", "domain"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(domain):
|
||||
return User.objects.create(email="Jane@Example.com", password="x", domain=domain)
|
||||
|
||||
|
||||
def test_list(api, user):
|
||||
res = api.get("/api/users/")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == [res.json()[0]]
|
||||
assert set(res.json()[0]) == FIELDS
|
||||
assert res.json()[0]["email"] == "jane@example.com"
|
||||
assert res.json()[0]["domain"] == user.domain_id
|
||||
|
||||
|
||||
def test_retrieve(api, user):
|
||||
res = api.get(f"/api/users/{user.pk}/")
|
||||
assert res.status_code == 200
|
||||
assert set(res.json()) == FIELDS
|
||||
assert str(user) == "jane@example.com"
|
||||
|
||||
|
||||
def test_retrieve_missing(api, db):
|
||||
assert api.get("/api/users/999/").status_code == 404
|
||||
|
||||
|
||||
def test_create_hashes_password(api, domain):
|
||||
data = {"email": "New@Example.com", "password": "hunter2", "domain": domain.pk}
|
||||
res = api.post("/api/users/", data, content_type="application/json")
|
||||
assert res.status_code == 201
|
||||
assert set(res.json()) == FIELDS
|
||||
assert res.json()["email"] == "new@example.com"
|
||||
stored = User.objects.get().password
|
||||
assert stored.startswith("$6$")
|
||||
assert sha512_crypt.verify("hunter2", stored)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data",
|
||||
[
|
||||
{"password": "p"},
|
||||
{"email": "nope", "password": "p"},
|
||||
{"email": "jane@example.com", "password": "p"},
|
||||
{"email": "a@example.com"},
|
||||
{"email": "a@example.com", "password": "p", "domain": 999},
|
||||
],
|
||||
)
|
||||
def test_create_invalid(api, user, data):
|
||||
data.setdefault("domain", user.domain_id)
|
||||
res = api.post("/api/users/", data, content_type="application/json")
|
||||
assert res.status_code == 400
|
||||
assert User.objects.count() == 1
|
||||
|
||||
|
||||
def test_update_hashes_password(api, user):
|
||||
other = Domain.objects.create(name="other.org")
|
||||
data = {"email": "jane@other.org", "password": "new-secret", "domain": other.pk}
|
||||
res = api.put(f"/api/users/{user.pk}/", data, content_type="application/json")
|
||||
assert res.status_code == 200
|
||||
assert res.json()["email"] == "jane@other.org"
|
||||
assert res.json()["domain"] == other.pk
|
||||
user.refresh_from_db()
|
||||
assert sha512_crypt.verify("new-secret", user.password)
|
||||
|
||||
|
||||
def test_update_invalid(api, user):
|
||||
res = api.put(f"/api/users/{user.pk}/", {"email": "x@example.com"}, content_type="application/json")
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
def test_partial_update_keeps_password(api, user):
|
||||
res = api.patch(f"/api/users/{user.pk}/", {"email": "J@example.com"}, content_type="application/json")
|
||||
assert res.status_code == 200
|
||||
assert res.json()["email"] == "j@example.com"
|
||||
user.refresh_from_db()
|
||||
assert user.password == "x"
|
||||
|
||||
|
||||
def test_partial_update_hashes_password(api, user):
|
||||
res = api.patch(f"/api/users/{user.pk}/", {"password": "p2"}, content_type="application/json")
|
||||
assert res.status_code == 200
|
||||
user.refresh_from_db()
|
||||
assert sha512_crypt.verify("p2", user.password)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["email", "password", "domain"])
|
||||
def test_partial_update_null(api, user, field):
|
||||
res = api.patch(f"/api/users/{user.pk}/", {field: None}, content_type="application/json")
|
||||
assert res.status_code == 400
|
||||
assert field in str(res.json())
|
||||
|
||||
|
||||
def test_delete(api, user):
|
||||
assert api.delete(f"/api/users/{user.pk}/").status_code == 204
|
||||
assert not User.objects.exists()
|
||||
|
||||
|
||||
def test_deleting_domain_cascades(api, user):
|
||||
assert api.delete(f"/api/domains/{user.domain_id}/").status_code == 204
|
||||
assert not User.objects.exists()
|
||||
@@ -0,0 +1,428 @@
|
||||
version = 1
|
||||
revision = 5
|
||||
requires-python = "==3.14.*"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asgiref"
|
||||
version = "3.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6b/90/f5fce14e95fccf53b58e8e2c47cd84d40d105d64073ae1ac75166db150e0/asgiref-3.13.0.tar.gz", hash = "sha256:034cc8cbf72d00553b4be256703ac545120b4e50af13b49dec99da899aeeecb4", size = 52941, upload-time = "2026-10-23T08:13:14.715Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/b0/ec211e1f7966247cb6469158d856bac9aa4c2fe13621bd6a352e4f850453/asgiref-3.13.0-py3-none-any.whl", hash = "sha256:f9596a2434512686473d50f96ff6207fbba49ae1a785dfac614df4aeb085674d", size = 27593, upload-time = "2026-10-23T08:13:13.394Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.10.23"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d2/79/e78bd8f75a9b8e32233ce92d4607f0a21ce88a9156cace2884e7175a82f5/coverage-7.17.0.tar.gz", hash = "sha256:cc010a0a7fbdd9bb8bdfd0d3f04a5151275860213cbd7e2b876b29778489c712", size = 991021, upload-time = "2026-10-22T16:06:50.009Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/e1/722462fe79912ebebc101497bd16c42413cde2dbae771017f1ec0acb84c2/coverage-7.17.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:dc5936491e306f2f6f095bd5fba593de11eb0375cee787ca1f245c3a333a523a", size = 227197, upload-time = "2026-10-22T16:05:09.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/74/826b9c09f2ebfea803bccb391d935688a25309bff8e819fc446bc629c108/coverage-7.17.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:968c5fb6e0c83cab752d79a70209648ce4d90cae815c60fa229b59e264601062", size = 227314, upload-time = "2026-10-22T16:05:11.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/19/c8514d7f5e0659a96ff2bc7377119dca6d420e2761c5fc69ae37618734f8/coverage-7.17.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:12cb8a20ad904a5811e6660d829a586b216e5254b9b34b76dde8a4d65d827dc6", size = 225477, upload-time = "2026-10-22T16:05:12.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/74/ef9433ce599f07d8633b4ce6e6d6921505c4ef3f7f9006f8eb66e2c278cf/coverage-7.17.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:bb5ca66937c55bc85d1a5e9c5900a3e4693a8edf2280a9b3672b7b2661a84b95", size = 225787, upload-time = "2026-10-22T16:05:14.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/66/26e58981940e8b3944c6a9941bec5c7f50840e7009a046712e3f3bcd9e6a/coverage-7.17.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5cc15faaf318ee5040d835a62c75bc6f122d837274f3b5efd47bf9fbd4ac5b85", size = 226019, upload-time = "2026-10-22T16:05:15.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/63/fe175310815cc8357b83b773c9b4c20943c269f217dd825a37c4b4bfe324/coverage-7.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d6d9d396c8b7bcaf76d774a10140b218111484e54c73a5f2a1b75655381748e", size = 226169, upload-time = "2026-10-22T16:05:17.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/b0/ac84761f5b4bd226696b0ec4606903d2ef7208abda0aec06c14a4218def4/coverage-7.17.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:42e6f170d1ac04b7eb3363310a6c41cdfd82c1fe6bf9ffbc4a86f380bdf091c7", size = 258092, upload-time = "2026-10-22T16:05:18.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/58/d8bdd65a661913bc12eaaa3e59742b2cb8b94f2e7f78e62184f0a58ca493/coverage-7.17.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:acdb5f3c96046efff9dc6c572a6acb2b8d0da09372d7b9a9ed7249cf7d17434f", size = 261096, upload-time = "2026-10-22T16:05:20.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/15/5d82e2f0a75ea149e0a6412e137e1d610538772fdfd7199737db7fe234da/coverage-7.17.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3393209f6894713161ee82a9b0e673e58237e0f9733c6638e7a6c0c620ef0b33", size = 262080, upload-time = "2026-10-22T16:05:21.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/cc/e3ebfd295d1dd47c7bc84a17d73f150465a37ab388dd68fe82b495dc79a0/coverage-7.17.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9494d9e99389d39e6a96227749af898d8924ff591cb63944f8a98e68582bf0e", size = 264558, upload-time = "2026-10-22T16:05:22.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/9d/e2adff76d6544bbc6df7366d74967923c2de37ba889a006bfb875bf5857b/coverage-7.17.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:032d4c2db4c677eb71d43f2f8b80bf64e9346f7a3f7b6ebb13f1782f3cc6c274", size = 257855, upload-time = "2026-10-22T16:05:24.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/fd/ecd1e9cddf1c36b5d03c438568b1f53e63db84bb3656539d71b454484c7d/coverage-7.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4343a9378b964eb3cb80fa90406385de91c7e982a769ca9314d848f93bc0d3f2", size = 259933, upload-time = "2026-10-22T16:05:25.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/b6/e17a19dccdbcad494c64691f71c1dba3e55c30625bc29ffbb88ac5a0aa8b/coverage-7.17.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:b609df3d6d900464207532c5170a65c6ede8d2e0b91f151f90008a866395cd76", size = 257972, upload-time = "2026-10-22T16:05:27.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/0d/37dac534709a3fa43a937c8b0604839bb44b8670b83d143a4b78fec20d1e/coverage-7.17.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a01ef92caeedfa4f2ad171a3a8e6fece4565de871d8bca428151bf9929f41048", size = 262271, upload-time = "2026-10-22T16:05:28.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/17/7c05186925b4d64a46c271dfd944e3f0c1ad89db9095c31a6df157a75df2/coverage-7.17.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a3914e2c98c34b94c26d2add3d2a61ccfd3534fc4eaa879ee290bdbb5184ba6c", size = 257604, upload-time = "2026-10-22T16:05:30.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/65/c241cdee66a0ff86c68f6d37328e735d10e3d30cd6ff66f356039eb688d2/coverage-7.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8a72b26dc5c9a9422e45472b1d67ba5caf273c524c8668cc04b23062663755e", size = 259714, upload-time = "2026-10-22T16:05:31.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/09/e93233fe2405574ce0a31ed201c0cba3a860e085db5d2828bf12b7dc0790/coverage-7.17.0-cp314-cp314-win32.whl", hash = "sha256:03baf0688e14a5a44ffd1eefa8928dfc92b14565e521174e072b31b971aa1657", size = 227914, upload-time = "2026-10-22T16:05:32.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/77/04979eb217edb94b94afabb4bcc179f60c746f68d3fd0b12688ba9195730/coverage-7.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:3a4459b0f389dd1e51aac3dbfc4ca35b30cae7dc99caa3c0a086d1997a985709", size = 228543, upload-time = "2026-10-22T16:05:34.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/38/155f2f2c6769f8c43eca8a5e581443faa0908828650090b97103b4a41376/coverage-7.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32ee5831b15ba239bef5d272ae586ca8acea4a661b5f2941f5a0d380e518b23", size = 228396, upload-time = "2026-10-22T16:05:35.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/55/d6751572d9445485c4c5a51ec6ae11caa5c11b7ef2448cdccc884f7f54d9/coverage-7.17.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9c083e253e96fdeeaf48133fe836cac86ace2db215d235700e7f7532420a200c", size = 226486, upload-time = "2026-10-22T16:05:37.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/a8/82d37760e537bfdb9fdee285fa1e1b45bc153293d23e2331c7befedd1990/coverage-7.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b88fb4d4d7608cbcebdc7ff7dbffc2115e3b81d6c33a79147cd79d54fcb080f4", size = 226861, upload-time = "2026-10-22T16:05:38.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/96/70ec00a9db6d7bdd3f914eba12057360769f5cb78a98456e71fe1937deb5/coverage-7.17.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2bf3ee38a40c0570c316b9e7507c472469586906a20f289705898528c31aaea8", size = 266849, upload-time = "2026-10-22T16:05:40.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/d8/d99be3bfbb208512f039c80b31e404138a86678fc929a17acd5b7135a6ae/coverage-7.17.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7c570293f001ea91598b90e90cb9b98fddee833e90bbede432cbd218331ccd8e", size = 269402, upload-time = "2026-10-22T16:05:41.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/50/6cbe8b5a5060edbd21ce8109c4e900f8b7656e8b72ede6dc4d82dc0b8bfb/coverage-7.17.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:791f9ee09814dd30d9562b4f4eb037a371485ec1c830b53999d2320d20c4e764", size = 272025, upload-time = "2026-10-22T16:05:42.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/05/d3633add01e5518dfdacfdbd1be41cb5c581412dd8bccd663edf6ef7fec8/coverage-7.17.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7c5672ecf1b497de7a0b37743bef40e3b58e3207e9fe2ed6682cb0420836a88", size = 273515, upload-time = "2026-10-22T16:05:44.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/d9/b83387780271bf9ea9f96a3aeb01c1c302c4494a927923c4fa70cd170a3f/coverage-7.17.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:337f87e62e1c5f43c8198d879f677c342d152f84d3f157add12558853cbb8eaf", size = 266725, upload-time = "2026-10-22T16:05:45.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/be/8e80b29764ae3f4d70d44952eee3f3927eb36897490fe799e400adb7f5da/coverage-7.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c8a58baa8eb6154c6645eadcc19b6c089deab78c24a612a83206f2a8a5992a7c", size = 269312, upload-time = "2026-10-22T16:05:47.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/c9/e702f588fe8d83ca844f133270ad0a49bf650c8cf260551ff6f11a6b6bbf/coverage-7.17.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fb5745048ee20f796b0e3ab61831049e749acff188563e2af32f58ede957ac1b", size = 266740, upload-time = "2026-10-22T16:05:48.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/11/ee13ad14b714d75ef07c490b6e43969574a8b8b8c6d67bc741fcbacbf9c3/coverage-7.17.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:7b5abcae74750302ac4bc77351eaee87c450e4abd3b6225eacde3858e260f8b2", size = 271036, upload-time = "2026-10-22T16:05:49.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/bc/854e8912c11c54aaf416e74602000589c6efbbe1f251dc90202fdabddcb6/coverage-7.17.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4e153f0bd6509dbf837fd585f489b3328bfd98f2a718a0b15ae63032148b285c", size = 266158, upload-time = "2026-10-22T16:05:51.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/dd/96432830ddf63bc42654de54eee77a0cd6949cc65769bda06f353323f23a/coverage-7.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e42a835271922fe7bf98c7a05c78d4032f4e52bf5b07fafa1eadfdfc2f0ad22f", size = 268088, upload-time = "2026-10-22T16:05:52.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ee/230c6ec065a6629d41cc89323ceb54d37e2650c104f70fc108f4ee7c5051/coverage-7.17.0-cp314-cp314t-win32.whl", hash = "sha256:5900055351ec89fdffb34b27898815106529ded29a9496464c1ebd6313fd40fa", size = 228229, upload-time = "2026-10-22T16:05:54.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/1d/23eb6aadcb3c00764ff2961c0ff55f5dfa2543856d932b949bbc04fa68e9/coverage-7.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5ee9c8bd13a59e301dc2aefe7aa8384633406808de051b1982775667094eb673", size = 229154, upload-time = "2026-10-22T16:05:55.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/f1/7df117e8e016b7a3ee459e833b3456f3610505afefd071f8727702d98439/coverage-7.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a9cb9a0fb58e6fff5442969af36094ecd6976f4907f179e33b27e15355d8f74", size = 228726, upload-time = "2026-10-22T16:05:57.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/17/48032919343afa2906112d458aceda2043afdb24a2ee8eb4c60904d8ddf1/coverage-7.17.0-py3-none-any.whl", hash = "sha256:bac487dce0d2aff28ddf1a533a916f771416eee0e493960f611e49037b878e9d", size = 217383, upload-time = "2026-10-22T16:06:48.471Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "5.2.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/85/fe/79e692b430c7721bf4b812c328bfe687e73e3f28f56611a6a18a97e887cc/django-5.2.18.tar.gz", hash = "sha256:461c5dd06d2ea16bd5ca37d3f46e4def1d6b0fe7588c6f4e2119517bb0af8b2d", size = 10910238, upload-time = "2026-10-06T13:01:09.371Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/01/6568ec52b26548ca1b83d33249f7274db3bf74698f42c0da42ec9db1a24a/django-5.2.18-py3-none-any.whl", hash = "sha256:92ed81d500be6408ecd704d7bd1366c534f30427bffcc63c5fefb129561aec7c", size = 8316071, upload-time = "2026-10-06T13:01:04.413Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-extensions"
|
||||
version = "4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/b3/ed0f54ed706ec0b54fd251cc0364a249c6cd6c6ec97f04dc34be5e929eac/django_extensions-4.1.tar.gz", hash = "sha256:7b70a4d28e9b840f44694e3f7feb54f55d495f8b3fa6c5c0e5e12bcb2aa3cdeb", size = 283078, upload-time = "2025-04-11T01:15:39.617Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-ninja"
|
||||
version = "1.7.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/8e/8c12dd873d76762e61fa7225bbab5d2b87dce358a8f7d764508b1363c17c/django_ninja-1.7.3.tar.gz", hash = "sha256:8a73ecccaa86639202427e81667b8fca6dfc250d2b6e5108369688029a4cb593", size = 2342134, upload-time = "2026-10-29T10:21:03.197Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/7d/7df99529f54178c67282d25983aad66bc0593ffbf5c1d420bb2840389c90/django_ninja-1.7.3-py3-none-any.whl", hash = "sha256:5e3c2dcead83a2512ea772dbc435ca51632cf46bf3be1523bac41e5bb394891c", size = 2376577, upload-time = "2026-10-29T10:21:01.199Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-postfix-dovecot-api"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "django-extensions" },
|
||||
{ name = "django-ninja" },
|
||||
{ name = "libpass" },
|
||||
{ name = "sentry-sdk" },
|
||||
{ name = "whitenoise" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-django" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
prod = [
|
||||
{ name = "gunicorn" },
|
||||
{ name = "mysqlclient" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "django", specifier = ">=5.2,<6" },
|
||||
{ name = "django-extensions", specifier = ">=4.1" },
|
||||
{ name = "django-ninja", specifier = ">=1.7.3" },
|
||||
{ name = "libpass", specifier = ">=1.9.4" },
|
||||
{ name = "sentry-sdk", specifier = ">=2.73.0" },
|
||||
{ name = "whitenoise", specifier = ">=6.12.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=9.2.0" },
|
||||
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||
{ name = "pytest-django", specifier = ">=4.14.0" },
|
||||
{ name = "ruff", specifier = ">=0.17.3" },
|
||||
]
|
||||
prod = [
|
||||
{ name = "gunicorn", specifier = ">=26.2.0" },
|
||||
{ name = "mysqlclient", specifier = ">=2.3.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gunicorn"
|
||||
version = "26.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d9/8a/e4ef6ee11701b6cd64702848415ffb69eeff85cb388a3c6c7fe86f22f3f8/gunicorn-26.2.0.tar.gz", hash = "sha256:62b864895d9ebff0b2f9867ba04fe811c93121596540830c9c916d0769668447", size = 787921, upload-time = "2026-08-24T15:05:59.3Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl", hash = "sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3", size = 228389, upload-time = "2026-08-24T15:05:57.67Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libpass"
|
||||
version = "1.9.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/2d/953db970fa7bccd1d74e2870158878ab5b46776f3f34180ca49ce93c4ac8/libpass-1.9.4.tar.gz", hash = "sha256:4ff9a5ebf51aad1e8164453bc29c4a06926cf175a9501d87205612ba247f3cbd", size = 704877, upload-time = "2026-10-17T07:49:01.714Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/35/d08a8175f20cb453b3133845afce81378437730f5cd402bb411a5fd54841/libpass-1.9.4-py3-none-any.whl", hash = "sha256:a608621af2d7d64579957988fac287f230f88792c83a2d0f895afb05ee429489", size = 307138, upload-time = "2026-10-17T07:49:00.39Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mysqlclient"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/8f/b9488795d21a76c1520905feba5afb6233f16510797a28b51f2b2688c93e/mysqlclient-2.3.0.tar.gz", hash = "sha256:bea8294964266f6486f1ca514ccfcdbc54d4fe0d32882b38c1d4594df870be8b", size = 102185, upload-time = "2026-09-14T15:38:30.784Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/5f/f62d1263a942e97ca34ea8bf151c13a638fea7a5a544aa9db3d6dad7671d/mysqlclient-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:5d4c53eb9c5625dd68b6fed32c8cf00ba19cd1c3073645dec309a9d16bd029e2", size = 222543, upload-time = "2026-09-14T15:38:22.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/e5/1de3e1fc27009a6da30a52167e98f52ab4277d089eaff7152f6156745af9/mysqlclient-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:cfe14103280d5a4968fe8a3ace2a8939ef68a1d881aec9872d06746106b49f7f", size = 213120, upload-time = "2026-09-14T15:38:23.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/ea/d7ebc53af9d3341e5c049795ec5e88946d83cbd26ee468475ae0c3b21d7f/mysqlclient-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:673891700dafbc66a6a8df12059b53a65905ec6e8dec615392fb984299880c0d", size = 222969, upload-time = "2026-09-14T15:38:24.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/42/9ff798c066e33df9f7b58822a3eaf26185c3cb7e61a8186a56243b006066/mysqlclient-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:fe27c63ba9088b28467bf276f93f0b4a9062beabaeeb9692593e774d1146cce2", size = 213680, upload-time = "2026-09-14T15:38:25.259Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.14.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/d5/b4867fa7c7f04c34997d7d8aad744f64d210bb99d67cf18bf3fb1eea1aed/pydantic-2.14.2.tar.gz", hash = "sha256:fb7b2db6cefe8414dd612edeede49c4c433acb0432b0f92b95024e45f66c505d", size = 911596, upload-time = "2026-10-29T16:37:12.476Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/97/01/c71a71258b58659c72504e5fd65b451f6e36e34dc931d0b237ded213c814/pydantic-2.14.2-py3-none-any.whl", hash = "sha256:7efbf7507b937931cd32a5755acb00ea1cf5ccb1d4e8067a5850d5069e382118", size = 490403, upload-time = "2026-10-29T16:37:10.181Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.50.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/8e/23ec1021834e35041fbb2f10946141e6acdfd39379c45cc4d65a4e8a1d98/pydantic_core-2.50.2.tar.gz", hash = "sha256:1b0e8dc3932cf4e9ed5727570325cacc716b9409ffc794a221c0ccc72e8eb775", size = 518711, upload-time = "2026-10-29T16:34:57.465Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/64/fc525bb981a9f287cfa34346e7ed1d5ef7c043c2b9b517f54c6136032d10/pydantic_core-2.50.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:efad0bdc67d3b119c3bab6b94d341a27333c2bb89ed75dfa182a4a06cd0ea329", size = 2226414, upload-time = "2026-10-29T16:32:58.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/68/315701095f25687938ab79d263a6d939337456c2840fe69038fd38000338/pydantic_core-2.50.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ffb4dce3ecb2cd563aa30f9eb5088a1cf1f4f688b90cbb374fa96e49fc50b02", size = 2095041, upload-time = "2026-10-29T16:32:59.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/4b/efd3abf31aa24bc97b311735f418886f947b4f193df02a1977643963b03e/pydantic_core-2.50.2-cp314-cp314-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:150f6e101f575c21a4ecfed1be1c05bb89a0ad49cb9c846ed377583bd71b32c2", size = 2257635, upload-time = "2026-10-29T16:33:01.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/6c/146b36eed4de8115a0fda76840703e75f5f8b36aeb3af86976bf094d1a17/pydantic_core-2.50.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1db9cab6197e797824f0479dd2ae5fd2e272aa61c61b7faa5a6e450ca7a0d3b7", size = 2116807, upload-time = "2026-10-29T16:33:02.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/94/db006ea79bbe405cc38b25899319c7d461ff1813cbdfe6aa90a65ed1dfff/pydantic_core-2.50.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:1b9f652e17a13bbc09b45f74c3c326cca72059ce4dcdef687fcb04389db413a9", size = 2201037, upload-time = "2026-10-29T16:33:03.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/f9/ea2bc1ec13a67af4a63c9a887fa0aa6ef4ade9018e2bb412d4fc80110e19/pydantic_core-2.50.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:823b222c4fc861a82c650998a6117ecbd150cc71bf8b78f9a31bf3f48c456108", size = 2374904, upload-time = "2026-10-29T16:33:05.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/8e/26a6a075301f0e563646037ea69bef8c63911d38fbd257f94cec69cdf985/pydantic_core-2.50.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:783abdfac21247d33bec40aa683d26bba30d61655c40de38b2decc4797aabf92", size = 2457626, upload-time = "2026-10-29T16:33:07.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/a2/8eeabaf1d394f0d3b9a27129ac21556eabee6f2ddcb418069ef4037e00b0/pydantic_core-2.50.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b29b7c5adf4b2be7c941f7f76432aa3269cd5052ff8610376b8ece24d7b02c51", size = 2245505, upload-time = "2026-10-29T16:33:08.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/54/449778cd397e2682b30d1bcfab0872f6ac36702b59cc326296b7f4f14928/pydantic_core-2.50.2-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:4ac996e42a93d80122a23edce44e02c6b9c4913f5c6198a077da31475341a39a", size = 2259563, upload-time = "2026-10-29T16:33:10.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/c2/2f65691d25488ee0f3c17861d079e8ccb7817955522d07cd3c3970d30af5/pydantic_core-2.50.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:99e1d482138a71902415ba375bb4627679a5fefd3bd07803c3a8f37632f4981d", size = 2290470, upload-time = "2026-10-29T16:33:11.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/32/aa7c40234cd24536afa0895ff1d26f9f15638004e25242c5193ce053b96b/pydantic_core-2.50.2-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:a53d5f11cd6d15569ba3e53f3498077f0d5e633f6ec604d2ea613e895017ebee", size = 2477272, upload-time = "2026-10-29T16:33:12.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/43/c347c68e040cf93d8487a8f4fc28f2768312834c7d21c301538f416f9955/pydantic_core-2.50.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23385c4626f8b5a7792aa381ea573d9432cfbf4c21375a0a5eed2a4fb64e0e58", size = 2484352, upload-time = "2026-10-29T16:33:14.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/8e/72556126c8b6154fe7acaf757b96b2daa37aeffc3a517deb76e272f5ad09/pydantic_core-2.50.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9a8a62281fd9c6a373b3fd3d473685e6838d43341eecde5f47729748e2c11ee3", size = 1491226, upload-time = "2026-10-29T16:33:15.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/66/004c85511812964a36b3535af022c5c0dfcb760a31e4905b96310e8dd6c8/pydantic_core-2.50.2-cp314-cp314-win32.whl", hash = "sha256:18f008ef58cd30782f81b6e0119e20b7e884edcefbc2d370162ebe033f6e5c99", size = 2113134, upload-time = "2026-10-29T16:33:16.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/5f/dcd3df20ab2a7279d42a8415fc184e8e77bcf9aa3c9ac3e938c1300a242c/pydantic_core-2.50.2-cp314-cp314-win_amd64.whl", hash = "sha256:b62c89545d21d930c333208fd548e7b4f262228ba3a322fd75c987a75ab11f31", size = 2253407, upload-time = "2026-10-29T16:33:18.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/fb/0685895f5d199b215011dc4eb8ccea032a64ec23dfa88eeb5acca939c2d2/pydantic_core-2.50.2-cp314-cp314-win_arm64.whl", hash = "sha256:6e731bf5a5e8e25398544d7709e39068ec4230b0177e4db7fa955692fd415ba3", size = 2160456, upload-time = "2026-10-29T16:33:19.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/2f/4c8f67efe62837a508b74cf910904f73029cb211371351c5ceb36d56e85e/pydantic_core-2.50.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:33c6800ab9563adaa0d7893e9cd8f5b972ef1e6a23df70498e05c24f3fcec5e1", size = 2217558, upload-time = "2026-10-29T16:33:21.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/73/e0ef88cfbcca5409f57751289bc20be63a58af596a36e94b8cd795e59747/pydantic_core-2.50.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f81df206b937d0dca5efb38c25fc99037d82dbcd3740e34c3f81997d2322ac1c", size = 2074335, upload-time = "2026-10-29T16:33:22.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/cc/47844a3179ebce27064e13619594b3fc1a9b4509d8dec1e3960c5f87f5bb/pydantic_core-2.50.2-cp314-cp314t-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:21ea3b00f1e87c66f3f9e92f9f6fec20495ac0e60d7d2e68ba1fc49739d7cf40", size = 2264201, upload-time = "2026-10-29T16:33:24.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/d1/30e9d3c86d81884ee3199560ddfa2c1a66a8dc0736441748eae43064abbc/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9eff570402175c7d5fff8497d5a9c05580664a79bb66c07b8f205d17f69e580b", size = 2094353, upload-time = "2026-10-29T16:33:26.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/8f/392a5999480d0bcab4539db0dafa93265152396fd63e87be4d24e13d6d82/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:240fec32a74af0979f7253532859b3fd209ad5eea822416bb010929dba445c3d", size = 2203119, upload-time = "2026-10-29T16:33:28.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/80/6e4e276a3fde5500224a39150e06a4f134f136165908be69402720718a36/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92acf02c770b9d5c4e9a3e8d64e9d95ae26456a86ebb7b7cf64a97f899e5871b", size = 2392881, upload-time = "2026-10-29T16:33:29.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/d5/5eb56c6317ab4abf33ef9519a50bfac0c96f7f408bad1bc36da6f27d43e4/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:1ac5b99bc7b0a68ec64f5f75a6828a7bab5535c8df0cdc07901deb50e0815910", size = 2457290, upload-time = "2026-10-29T16:33:30.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/9e/f1fe9384bb37e4e728c51c79151808deacf7b2502e245f50b57c85eeb199/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:713d7c33c0e231e3786eb0ec95885814dd65d5f3a09754399b6acbf5e9365f11", size = 2241568, upload-time = "2026-10-29T16:33:32.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/f1/cffc4f8456c35690bcb65a8f2304afbf2f519766345a28aba553d5a55cc8/pydantic_core-2.50.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:61e13100b30d8d25fc0d1f0297014b32941bf0975a3584bd08f3adebb68d1ead", size = 2257185, upload-time = "2026-10-29T16:33:33.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/34/ccd4df1cc39cb1e42fb7ec86a0dc0a23e8479de04d3012ca6b0bf2279a03/pydantic_core-2.50.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:523fe6574aea50f456cbc301c1cdddcbf237939f7657f9af99b4f0f71942b59c", size = 2299384, upload-time = "2026-10-29T16:33:34.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/60/404b4b0c416a61867ee642cdc1450bca4d8a9a10e5f5f21e1ecaad05fdc8/pydantic_core-2.50.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:6c25b8826be85714b27dafe908d3461e1b09e3e31c0279a7029040c832c781ef", size = 2479384, upload-time = "2026-10-29T16:33:36.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/0d/57c81418a2fdfde0578c71396df773965adebd4421d9985a0f8dcbe9ec4d/pydantic_core-2.50.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a59db03b80a156a6fbeae41096866d1cb89cdf31031ba44c768a804538880636", size = 2504616, upload-time = "2026-10-29T16:33:37.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/66/dec78e28a604f3f4985f5a795d4da3e5fef78b0921aeb7f614ed7c780a32/pydantic_core-2.50.2-cp314-cp314t-win32.whl", hash = "sha256:0d090ae76d5b09c003600c7779b3adcf3d1b49268f616c5c51d844e54c8e318d", size = 2093179, upload-time = "2026-10-29T16:33:38.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/ea/79d9ee60648463804145d44b08f1e7e2474427d9704e3331d814483908b3/pydantic_core-2.50.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9b01a092955c1c7530196ad526755cff4f2d11f37ca9a6fc9ebe37ca49a48977", size = 2220575, upload-time = "2026-10-29T16:33:39.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/01/aa6a7bd017c3228a41942da4fd788b86fb4aad9677212a4737a0c157fe74/pydantic_core-2.50.2-cp314-cp314t-win_arm64.whl", hash = "sha256:4ea01f30e119f1ec7a2034e85c40f491d469896aef02cd6436fd9292fe1be3fb", size = 2160866, upload-time = "2026-10-29T16:33:41.237Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-django"
|
||||
version = "4.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/44/f6/3851312120c2bf2f19cafff931e75059aad1ba670703cd751e2fde9bc942/pytest_django-4.14.0.tar.gz", hash = "sha256:26787dd3f422cfbab8f55b80a776e2edea7a11092cb74e960bef1312515708ef", size = 94700, upload-time = "2026-08-10T14:13:08.319Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/03/850bffad2b581c440ca51c039d74504d5a422c94bda0bdb8a8ba5068d48b/pytest_django-4.14.0-py3-none-any.whl", hash = "sha256:c533b08d89cc675efcd5398eea270b34547e35f9a3608e2c9748dd88428ea187", size = 27067, upload-time = "2026-08-10T14:13:06.998Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.17.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sentry-sdk"
|
||||
version = "2.73.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c5/3b/1e47acf2456781c26e12007379ecc154bf310b8f64d40390d78b791d55c3/sentry_sdk-2.73.0.tar.gz", hash = "sha256:4a2ab97b16e4693c325ab361c7bbbb346dbc7d0bc0150922d795a25bac8d5280", size = 1079078, upload-time = "2026-10-22T09:08:49.63Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/88/cabf98d017d7b940720e514e5f8ee02e3e6f7495caf93c30735a38387ec4/sentry_sdk-2.73.0-py3-none-any.whl", hash = "sha256:0bcfa6928ea5adbb89591858b514d27adac27c7a3cccc31b20ed10e0b09eda9e", size = 549011, upload-time = "2026-10-22T09:08:47.88Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlparse"
|
||||
version = "0.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/e0/361c764ea58cc1a75f7b4b424a74b08449db8bfa5e9199c7275999fdd1b9/sqlparse-0.6.1.tar.gz", hash = "sha256:ca722573f848ff2d30e40ea30f53a104b7dea7635ea9285891263eb2c58fb6e7", size = 182236, upload-time = "2026-11-01T18:06:45.951Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/ff/2fee788e82e75be8f210c65c5d103bf44c880e3769c884652a4cd911b8ba/sqlparse-0.6.1-py3-none-any.whl", hash = "sha256:3be5a72d04c873d690d7c6dedd33d6fae2ef82759881ceec02fc2efd0cba564f", size = 51200, upload-time = "2026-11-01T18:06:47.434Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2026.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/29/6a1391d31cd00ff9e927215a224a880e1e84e826b64ba80a499e51c074d8/tzdata-2026.6.tar.gz", hash = "sha256:85d2cd9c09f81f0bb29db27759fc83af4f1e620efb814f8c5bf8b4cc4feece6d", size = 201017, upload-time = "2026-10-28T09:57:09.137Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/66/9a06c2227fedfd4919f0eaaa8b94377d9730882a7e4f95823c9a331ab5e1/tzdata-2026.6-py2.py3-none-any.whl", hash = "sha256:0a10eec6e13ef16b627fabed9a5f2ba61a62b1c67f327305f4e7e412a70b4aec", size = 348210, upload-time = "2026-10-28T09:57:07.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whitenoise"
|
||||
version = "6.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" },
|
||||
]
|
||||
Reference in new issue
Block a user