Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7efa230deb
|
||
|
|
8f58d7fcf9
|
||
|
|
7cd4b5dfc6
|
||
|
|
a21b4c9417
|
||
|
|
5474ad7bdc
|
||
|
|
9350064c63
|
||
|
|
712f370245
|
||
|
|
cad499c19a
|
||
|
|
08ffd39674
|
||
|
|
5ef366f667
|
||
|
|
83eb8f07f2
|
||
|
|
cd8de0847c
|
||
|
|
dd634d3717
|
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
-2
@@ -1,7 +1,10 @@
|
|||||||
*.swp
|
*.swp
|
||||||
*.pyc
|
*.pyc
|
||||||
.idea
|
.idea
|
||||||
venv
|
|
||||||
dpdapi/local.py
|
|
||||||
local_settings.py
|
local_settings.py
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
|
venv
|
||||||
|
.venv
|
||||||
|
.coverage
|
||||||
|
.pytest_cache
|
||||||
|
/static
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
default:
|
|
||||||
image: python:3.8-slim
|
|
||||||
cache:
|
|
||||||
paths:
|
|
||||||
- .cache/pip
|
|
||||||
- .venv
|
|
||||||
|
|
||||||
test:
|
|
||||||
before_script:
|
|
||||||
- poetry install
|
|
||||||
script:
|
|
||||||
- bin/ensure-python-deps.sh
|
|
||||||
- poetry run python manage.py migrate
|
|
||||||
- poetry run python manage.py check
|
|
||||||
- poetry run coverage run --source=dpdapi --omit=dpdapi/migrations/* -m pytest -v
|
|
||||||
- poetry run coverage report
|
|
||||||
- poetry run coverage xml
|
|
||||||
- poetry run bin/lint
|
|
||||||
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
|
|
||||||
artifacts:
|
|
||||||
reports:
|
|
||||||
coverage_report:
|
|
||||||
coverage_format: cobertura
|
|
||||||
path: coverage.xml
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
stage: deploy
|
|
||||||
before_script:
|
|
||||||
# Install ssh if not already
|
|
||||||
- 'command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )'
|
|
||||||
- eval $(ssh-agent -s)
|
|
||||||
# Make SSH key available
|
|
||||||
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
|
|
||||||
- mkdir -p ~/.ssh
|
|
||||||
- chmod 700 ~/.ssh
|
|
||||||
# Add known host keys
|
|
||||||
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
|
||||||
- chmod 644 ~/.ssh/known_hosts
|
|
||||||
script:
|
|
||||||
- ssh gitdeploy@lynx.neuf.no "$(< bin/deploy)"
|
|
||||||
only:
|
|
||||||
- main
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# See https://pre-commit.com for more information
|
|
||||||
# See https://pre-commit.com/hooks.html for more hooks
|
|
||||||
repos:
|
|
||||||
- repo: local
|
|
||||||
hooks:
|
|
||||||
- id: black
|
|
||||||
name: black
|
|
||||||
entry: black
|
|
||||||
args: [--check]
|
|
||||||
language: system
|
|
||||||
require_serial: true
|
|
||||||
types: [python]
|
|
||||||
- id: isort
|
|
||||||
name: isort
|
|
||||||
entry: isort
|
|
||||||
args: [--check]
|
|
||||||
language: system
|
|
||||||
types: [python]
|
|
||||||
- id: pycln
|
|
||||||
name: pycln
|
|
||||||
entry: pycln
|
|
||||||
args: [--check]
|
|
||||||
language: system
|
|
||||||
types: [python]
|
|
||||||
- id: pyupgrade
|
|
||||||
name: pyupgrade
|
|
||||||
entry: pyupgrade
|
|
||||||
args: [--py38-plus]
|
|
||||||
language: system
|
|
||||||
types: [python]
|
|
||||||
- id: django-upgrade
|
|
||||||
name: django-upgrade
|
|
||||||
entry: django-upgrade
|
|
||||||
language: system
|
|
||||||
types: [python]
|
|
||||||
args: [--target-version, "3.2"]
|
|
||||||
@@ -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,20 +1,23 @@
|
|||||||
# django-postfix-dovecot-api
|
# django-postfix-dovecot-api
|
||||||
|
|
||||||
## Getting started
|
REST API for managing the virtual domains, users and aliases that Postfix and Dovecot read from the database.
|
||||||
|
Used by [mailadmin](https://git.neuf.no/edb/mailadmin).
|
||||||
|
|
||||||
```bash
|
## Development
|
||||||
sudo apt install libmysqlclient-dev
|
|
||||||
poetry install
|
```sh
|
||||||
poetry run bin/run
|
mise install
|
||||||
|
uv sync
|
||||||
|
prek install
|
||||||
|
uv run python manage.py migrate
|
||||||
|
uv run python manage.py runserver
|
||||||
|
uv run pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deploy
|
API docs are served at `/api/docs`. Override settings (database, secret key, Sentry) in
|
||||||
|
`dpdapisite/local_settings.py`.
|
||||||
|
|
||||||
First you need to have your public key in `/home/gitdeploy/.ssh/authorized_keys` on the server and then you can do:
|
## Deployment
|
||||||
|
|
||||||
fab deploy
|
CI builds an image for each push to `main` and deploys it to lynx while the repository variable `DEPLOY_LYNX` is
|
||||||
|
`true`. The image reads its settings from the environment (`dpdapisite/settings_prod.py`).
|
||||||
## Inspiration:
|
|
||||||
-
|
|
||||||
- http://modoboa.readthedocs.org/
|
|
||||||
- https://github.com/lgunsch/django-vmail
|
|
||||||
-18
@@ -1,18 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
export DJANGO_SETTINGS_MODULE=dusken.settings.prod
|
|
||||||
PROJECT_PATH=/var/www/neuf.no/django-postfix-dovecot-api
|
|
||||||
PROCESS_NAME="mxapi.neuf.no"
|
|
||||||
|
|
||||||
echo "Deploying mxapi to ${PROJECT_PATH}"
|
|
||||||
cd "$PROJECT_PATH" || exit 1
|
|
||||||
git pull
|
|
||||||
|
|
||||||
poetry install
|
|
||||||
umask 022; poetry run python manage.py collectstatic --noinput -c # Collect static
|
|
||||||
poetry run python manage.py migrate # Run DB migrations
|
|
||||||
|
|
||||||
# Reload backend
|
|
||||||
echo "Reloading supervisor process ${PROCESS_NAME}"
|
|
||||||
sudo /usr/bin/supervisorctl pid "$PROCESS_NAME" | xargs kill -HUP
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
poetry config virtualenvs.in-project true
|
|
||||||
poetry run pip install -U pip wheel
|
|
||||||
poetry install
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
SRC_DIR="dpdapi"
|
|
||||||
|
|
||||||
black $SRC_DIR
|
|
||||||
isort $SRC_DIR
|
|
||||||
pycln $SRC_DIR
|
|
||||||
pyupgrade --py38-plus $(find $SRC_DIR -name '*.py')
|
|
||||||
django-upgrade --target-version 3.2 $(find $SRC_DIR -name '*.py')
|
|
||||||
+3
-3
@@ -5,9 +5,9 @@ from dpdapi.models import Alias, Domain, User
|
|||||||
|
|
||||||
@admin.register(Alias)
|
@admin.register(Alias)
|
||||||
class AliasAdmin(admin.ModelAdmin):
|
class AliasAdmin(admin.ModelAdmin):
|
||||||
list_display = ["source", "destination", "active", "domain"]
|
list_display = ("source", "destination", "active", "domain")
|
||||||
list_filter = ["domain"]
|
list_filter = ("domain",)
|
||||||
search_fields = ["source", "destination"]
|
search_fields = ("source", "destination")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Domain)
|
@admin.register(Domain)
|
||||||
|
|||||||
+272
@@ -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,15 +0,0 @@
|
|||||||
import django_filters
|
|
||||||
from rest_framework.filters import BaseFilterBackend
|
|
||||||
|
|
||||||
from dpdapi.models import Alias
|
|
||||||
|
|
||||||
|
|
||||||
class AliasRegexFilter(django_filters.FilterSet):
|
|
||||||
class Meta:
|
|
||||||
model = Alias
|
|
||||||
fields = {"source": ["iregex", "regex"], "destination": ["iregex", "regex"]}
|
|
||||||
|
|
||||||
|
|
||||||
class AliasRegexFilterBackend(BaseFilterBackend):
|
|
||||||
def filter_queryset(self, request, queryset, view):
|
|
||||||
return AliasRegexFilter(request.query_params, queryset=queryset).qs
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import os
|
from pathlib import Path
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db.utils import IntegrityError
|
from django.db import IntegrityError, transaction
|
||||||
|
|
||||||
from dpdapi.models import Alias, Domain
|
from dpdapi.models import Alias, Domain
|
||||||
|
|
||||||
@@ -9,54 +9,52 @@ from dpdapi.models import Alias, Domain
|
|||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Import aliases for a single domain at a time, which is the domain of the first source address."
|
help = "Import aliases for a single domain at a time, which is the domain of the first source address."
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def add_arguments(self, parser):
|
||||||
if len(args) == 1:
|
parser.add_argument("filename", nargs="?")
|
||||||
if os.path.exists(args[0]):
|
|
||||||
self.import_file(args[0])
|
def handle(self, *args, filename=None, **options):
|
||||||
else:
|
if filename is None:
|
||||||
self.stdout.write(f"{args[0]} does not exist")
|
|
||||||
else:
|
|
||||||
self.stdout.write("Missing argument")
|
self.stdout.write("Missing argument")
|
||||||
|
elif not Path(filename).exists():
|
||||||
|
self.stdout.write(f"{filename} does not exist")
|
||||||
|
else:
|
||||||
|
self.import_file(filename)
|
||||||
|
|
||||||
def import_file(self, filename):
|
def import_file(self, filename):
|
||||||
new_aliases = []
|
new_aliases = []
|
||||||
counts = {"imported": 0, "existing": 0}
|
counts = {"imported": 0, "existing": 0}
|
||||||
# Open file
|
with Path(filename).open() as f:
|
||||||
with open(filename) as f:
|
for line in f:
|
||||||
# Parse
|
if line.startswith("*: "):
|
||||||
for l in f:
|
|
||||||
if l.startswith("*: "):
|
|
||||||
continue # Skip catch all
|
continue # Skip catch all
|
||||||
|
|
||||||
source, destinations = l.strip().split(": ")
|
source, destinations = line.strip().split(": ")
|
||||||
for destination in destinations.split(","):
|
for destination in destinations.split(","):
|
||||||
if destination.startswith('"|') or "-admin@" in destination:
|
if destination.startswith('"|') or "-admin@" in destination:
|
||||||
continue # Skip mailman-aliases
|
continue # Skip mailman-aliases
|
||||||
new_aliases.append(Alias(source=source, destination=destination))
|
new_aliases.append(Alias(source=source, destination=destination))
|
||||||
|
|
||||||
if len(new_aliases) == 0:
|
if not new_aliases:
|
||||||
self.stdout.write("No aliases found")
|
self.stdout.write("No aliases found")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Lookup domain
|
# Assumes that every alias is in the same domain
|
||||||
domain_name = new_aliases[0].source.split("@")[1] # Assumes that every alias is in the same domain
|
domain_name = new_aliases[0].source.split("@")[1]
|
||||||
d, created = Domain.objects.get_or_create(name=domain_name)
|
domain, created = Domain.objects.get_or_create(name=domain_name)
|
||||||
if created:
|
if created:
|
||||||
self.stdout.write(f"Created domain {d.name}.")
|
self.stdout.write(f"Created domain {domain.name}.")
|
||||||
|
|
||||||
# Try to create, log if exists. Slow but works
|
# Try to create, log if exists. Slow but works
|
||||||
for a in new_aliases:
|
for alias in new_aliases:
|
||||||
a.domain = d
|
alias.domain = domain
|
||||||
try:
|
try:
|
||||||
a.save()
|
with transaction.atomic():
|
||||||
counts["imported"] += 1 # Woop!
|
alias.save()
|
||||||
|
counts["imported"] += 1
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
counts["existing"] += 1 # Exists
|
counts["existing"] += 1
|
||||||
self.stdout.write(f"Could not import '{a.source}={a.destination}', already exists.")
|
self.stdout.write(f"Could not import '{alias.source}={alias.destination}', already exists.")
|
||||||
|
|
||||||
# Print num imported, existing, total
|
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
"Imported {}, skipped {} existing, total {}".format(
|
f"Imported {counts['imported']}, skipped {counts['existing']} existing, total {len(new_aliases)}"
|
||||||
counts["imported"], counts["existing"], len(new_aliases)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
@@ -1,58 +1,62 @@
|
|||||||
from django.db import migrations, models
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import models, migrations
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = []
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Alias",
|
name='Alias',
|
||||||
fields=[
|
fields=[
|
||||||
("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)),
|
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||||
("created", models.DateTimeField(auto_now_add=True)),
|
('created', models.DateTimeField(auto_now_add=True)),
|
||||||
("updated", models.DateTimeField(auto_now=True)),
|
('updated', models.DateTimeField(auto_now=True)),
|
||||||
("source", models.EmailField(max_length=254)),
|
('source', models.EmailField(max_length=254)),
|
||||||
("destination", models.EmailField(max_length=254)),
|
('destination', models.EmailField(max_length=254)),
|
||||||
("active", models.BooleanField(default=True)),
|
('active', models.BooleanField(default=True)),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
"verbose_name_plural": "aliases",
|
'verbose_name_plural': 'aliases',
|
||||||
},
|
},
|
||||||
bases=(models.Model,),
|
bases=(models.Model,),
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Domain",
|
name='Domain',
|
||||||
fields=[
|
fields=[
|
||||||
("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)),
|
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||||
("created", models.DateTimeField(auto_now_add=True)),
|
('created', models.DateTimeField(auto_now_add=True)),
|
||||||
("updated", models.DateTimeField(auto_now=True)),
|
('updated', models.DateTimeField(auto_now=True)),
|
||||||
("name", models.CharField(unique=True, max_length=254)),
|
('name', models.CharField(unique=True, max_length=254)),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
"abstract": False,
|
'abstract': False,
|
||||||
},
|
},
|
||||||
bases=(models.Model,),
|
bases=(models.Model,),
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="User",
|
name='User',
|
||||||
fields=[
|
fields=[
|
||||||
("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)),
|
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||||
("created", models.DateTimeField(auto_now_add=True)),
|
('created', models.DateTimeField(auto_now_add=True)),
|
||||||
("updated", models.DateTimeField(auto_now=True)),
|
('updated', models.DateTimeField(auto_now=True)),
|
||||||
("email", models.EmailField(unique=True, max_length=254)),
|
('email', models.EmailField(unique=True, max_length=254)),
|
||||||
("password", models.CharField(max_length=109)),
|
('password', models.CharField(max_length=109)),
|
||||||
("domain", models.ForeignKey(to="dpdapi.Domain", on_delete=models.CASCADE)),
|
('domain', models.ForeignKey(to='dpdapi.Domain', on_delete=models.CASCADE)),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
"abstract": False,
|
'abstract': False,
|
||||||
},
|
},
|
||||||
bases=(models.Model,),
|
bases=(models.Model,),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="alias",
|
model_name='alias',
|
||||||
name="domain",
|
name='domain',
|
||||||
field=models.ForeignKey(to="dpdapi.Domain", on_delete=models.CASCADE),
|
field=models.ForeignKey(to='dpdapi.Domain', on_delete=models.CASCADE),
|
||||||
preserve_default=True,
|
preserve_default=True,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
from django.db import migrations, models
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import models, migrations
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("dpdapi", "0001_initial"),
|
('dpdapi', '0001_initial'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterUniqueTogether(
|
migrations.AlterUniqueTogether(
|
||||||
name="alias",
|
name='alias',
|
||||||
unique_together={("source", "destination", "domain")},
|
unique_together=set([('source', 'destination', 'domain')]),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("dpdapi", "0002_auto_20150118_1638"),
|
('dpdapi', '0002_auto_20150118_1638'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="user",
|
model_name='user',
|
||||||
name="password",
|
name='password',
|
||||||
field=models.CharField(max_length=512),
|
field=models.CharField(max_length=512),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
+4
-4
@@ -15,6 +15,10 @@ class Alias(BaseModel):
|
|||||||
domain = models.ForeignKey("dpdapi.Domain", models.CASCADE)
|
domain = models.ForeignKey("dpdapi.Domain", models.CASCADE)
|
||||||
active = models.BooleanField(default=True)
|
active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name_plural = "aliases"
|
||||||
|
unique_together = ("source", "destination", "domain")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.source}={self.destination}"
|
return f"{self.source}={self.destination}"
|
||||||
|
|
||||||
@@ -24,10 +28,6 @@ class Alias(BaseModel):
|
|||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name_plural = "aliases"
|
|
||||||
unique_together = ("source", "destination", "domain")
|
|
||||||
|
|
||||||
|
|
||||||
class Domain(BaseModel):
|
class Domain(BaseModel):
|
||||||
name = models.CharField(max_length=254, unique=True)
|
name = models.CharField(max_length=254, unique=True)
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
from passlib.hash import sha512_crypt
|
|
||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
from .models import Alias, Domain, User
|
|
||||||
|
|
||||||
|
|
||||||
class AliasSerializer(serializers.ModelSerializer):
|
|
||||||
def create(self, validated_data):
|
|
||||||
# Workaround to accept duplicates
|
|
||||||
# FIXME, will fail with existing alias with validated_data['active']=False
|
|
||||||
alias, created = Alias.objects.get_or_create(**validated_data)
|
|
||||||
return alias
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Alias
|
|
||||||
validators = [] # Disable unique togheter check
|
|
||||||
fields = "__all__"
|
|
||||||
|
|
||||||
|
|
||||||
class AliasDeleteSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = Alias
|
|
||||||
validators = [] # No Unique togheter check on deletion
|
|
||||||
fields = "__all__"
|
|
||||||
|
|
||||||
|
|
||||||
class DomainSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = Domain
|
|
||||||
fields = "__all__"
|
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = User
|
|
||||||
fields = "__all__"
|
|
||||||
extra_kwargs = {"password": {"write_only": True}}
|
|
||||||
|
|
||||||
def _hash_password(self, raw_password):
|
|
||||||
return sha512_crypt.encrypt(raw_password)
|
|
||||||
|
|
||||||
def create(self, validated_data):
|
|
||||||
user = User(
|
|
||||||
email=validated_data["email"],
|
|
||||||
domain=validated_data["domain"],
|
|
||||||
password=self._hash_password(validated_data["password"]),
|
|
||||||
)
|
|
||||||
user.save()
|
|
||||||
return user
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
from django.contrib.auth.models import User
|
|
||||||
from django.urls import reverse
|
|
||||||
from rest_framework.test import APITestCase
|
|
||||||
|
|
||||||
from dpdapi.models import Alias, Domain
|
|
||||||
|
|
||||||
|
|
||||||
class RegexTestCase(APITestCase):
|
|
||||||
aliases = ["ops-alert@example.com", "yolo-announce@example.com"]
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
user = User.objects.create(username="yolo")
|
|
||||||
self.domain = Domain.objects.create(name="example.com")
|
|
||||||
for a in self.aliases:
|
|
||||||
Alias.objects.create(source=a, destination="ninja@example.com", domain=self.domain)
|
|
||||||
self.client.force_login(user)
|
|
||||||
|
|
||||||
def test_get_all(self):
|
|
||||||
res = self.client.get(reverse("alias-list"))
|
|
||||||
self.assertEqual(len(res.data), len(self.aliases))
|
|
||||||
|
|
||||||
def test_domain_filter(self):
|
|
||||||
params = {"domain__name": "example.com"}
|
|
||||||
res = self.client.get(reverse("alias-list"), data=params)
|
|
||||||
self.assertEqual(len(res.data), len(self.aliases))
|
|
||||||
|
|
||||||
def test_domain_filter_no_hits(self):
|
|
||||||
params = {"domain__name": "zexample.com"}
|
|
||||||
res = self.client.get(reverse("alias-list"), data=params)
|
|
||||||
self.assertEqual(len(res.data), 0)
|
|
||||||
|
|
||||||
def test_regex_filter(self):
|
|
||||||
params = {"source__regex": "^ops-|^ops@"}
|
|
||||||
res = self.client.get(reverse("alias-list"), data=params)
|
|
||||||
self.assertEqual(len(res.data), 1)
|
|
||||||
|
|
||||||
def test_delete_bulk_single(self):
|
|
||||||
data = {"destination": "ninja@example.com", "source": "ding@example.com", "domain": self.domain.pk}
|
|
||||||
Alias.objects.create(source=data["source"], destination=data["destination"], domain=self.domain)
|
|
||||||
res = self.client.delete(reverse("alias-delete-bulk"), data=[data], format="json")
|
|
||||||
self.assertEqual(res.status_code, 204, res.data)
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
from django.urls import include, path, re_path
|
|
||||||
from django.views.generic import RedirectView
|
|
||||||
from rest_framework import urls as rest_framework_urls
|
|
||||||
from rest_framework.routers import DefaultRouter
|
|
||||||
|
|
||||||
from .views import AliasViewSet, DomainViewSet, UserViewSet
|
|
||||||
|
|
||||||
api_router = DefaultRouter()
|
|
||||||
api_router.register(r"aliases", AliasViewSet)
|
|
||||||
api_router.register(r"domains", DomainViewSet)
|
|
||||||
api_router.register(r"users", UserViewSet)
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path("", RedirectView.as_view(pattern_name="api-root")),
|
|
||||||
re_path(r"^api/", include(api_router.urls)),
|
|
||||||
re_path(r"^admin/", admin.site.urls),
|
|
||||||
re_path(r"^api-auth/", include(rest_framework_urls, namespace="rest_framework")),
|
|
||||||
]
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import logging
|
|
||||||
import operator
|
|
||||||
from functools import reduce
|
|
||||||
|
|
||||||
from django.db.models import Q
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
|
||||||
from rest_framework import status, viewsets
|
|
||||||
from rest_framework.decorators import action
|
|
||||||
from rest_framework.mixins import ListModelMixin
|
|
||||||
from rest_framework.permissions import IsAuthenticated
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework.viewsets import GenericViewSet
|
|
||||||
|
|
||||||
from dpdapi.filters import AliasRegexFilterBackend
|
|
||||||
from dpdapi.models import Alias, Domain, User
|
|
||||||
from dpdapi.serializers import AliasDeleteSerializer, AliasSerializer, DomainSerializer, UserSerializer
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class AliasViewSet(ListModelMixin, GenericViewSet):
|
|
||||||
"""Aliases"""
|
|
||||||
|
|
||||||
queryset = Alias.objects.all()
|
|
||||||
serializer_class = AliasSerializer
|
|
||||||
permission_classes = [IsAuthenticated]
|
|
||||||
filter_backends = (DjangoFilterBackend, AliasRegexFilterBackend)
|
|
||||||
filterset_fields = (
|
|
||||||
"domain",
|
|
||||||
"domain__name",
|
|
||||||
)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["post"])
|
|
||||||
def create_bulk(self, request):
|
|
||||||
serializer = AliasSerializer(data=request.data, many=True)
|
|
||||||
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
|
|
||||||
serializer.save()
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["delete"])
|
|
||||||
def delete_bulk(self, request):
|
|
||||||
serializer = AliasDeleteSerializer(data=request.data, many=True)
|
|
||||||
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
|
|
||||||
# Ref: http://michelepasin.org/blog/2010/07/20/the-power-of-djangos-q-objects/
|
|
||||||
q_list = map(lambda x: Q(**x), serializer.initial_data)
|
|
||||||
aliases = Alias.objects.filter(reduce(operator.or_, q_list))
|
|
||||||
|
|
||||||
initial_set = set(map(lambda x: (x["source"], x["destination"], x["domain"]), serializer.initial_data))
|
|
||||||
deleted_set = set(aliases.values_list("source", "destination", "domain"))
|
|
||||||
diff = initial_set.difference(deleted_set)
|
|
||||||
logger.debug("Deleted: {}".format(",".join(map(lambda x: "{0[0]}={0[1]}".format(x), deleted_set))))
|
|
||||||
aliases.delete()
|
|
||||||
|
|
||||||
if diff:
|
|
||||||
diff_formatted = map(lambda x: "{0[0]}={0[1]}".format(x), diff)
|
|
||||||
res = "Aliases '{}' could not be found and were not deleted.".format(", ".join(diff_formatted))
|
|
||||||
logger.info(res)
|
|
||||||
return Response({"result": res}, status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
|
|
||||||
class DomainViewSet(viewsets.ModelViewSet):
|
|
||||||
"""Domains"""
|
|
||||||
|
|
||||||
queryset = Domain.objects.all()
|
|
||||||
serializer_class = DomainSerializer
|
|
||||||
permission_classes = [IsAuthenticated]
|
|
||||||
filter_backends = (DjangoFilterBackend,)
|
|
||||||
filterset_fields = ("name",)
|
|
||||||
|
|
||||||
|
|
||||||
class UserViewSet(viewsets.ModelViewSet):
|
|
||||||
"""Users"""
|
|
||||||
|
|
||||||
queryset = User.objects.all()
|
|
||||||
serializer_class = UserSerializer
|
|
||||||
permission_classes = [IsAuthenticated]
|
|
||||||
Whitespace-only changes.
@@ -1,14 +1,26 @@
|
|||||||
""" Ref: https://docs.djangoproject.com/en/3.2/topics/settings/ """
|
"""
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/stable/topics/settings/
|
||||||
|
|
||||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
For the full list of settings and their values, see
|
||||||
import os
|
https://docs.djangoproject.com/en/stable/ref/settings/
|
||||||
|
"""
|
||||||
|
|
||||||
import sentry_sdk
|
from contextlib import suppress
|
||||||
from sentry_sdk.integrations.django import DjangoIntegration
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
# See https://docs.djangoproject.com/en/stable/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
SECRET_KEY = "django-insecure-hlaf#yma(8s!cy-bf5e6kc&_5n_l1h!34c(j32-b3v8o7pg$5n" # noqa: S105
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
|
|
||||||
SECRET_KEY = os.getenv("SECRET_KEY", "hlaf#yma(8s!cy-bf5e6kc&_5n_l1h!34c(j32-b3v8o7pg$5n")
|
|
||||||
DEBUG = bool(os.getenv("DEBUG", True))
|
|
||||||
ALLOWED_HOSTS = []
|
ALLOWED_HOSTS = []
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
@@ -20,15 +32,16 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"django_extensions",
|
"django_extensions",
|
||||||
"rest_framework",
|
"ninja",
|
||||||
"django_filters",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
INSTALLED_APPS += [
|
INSTALLED_APPS += [
|
||||||
"dpdapi",
|
"dpdapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
@@ -36,7 +49,9 @@ MIDDLEWARE = [
|
|||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
]
|
]
|
||||||
ROOT_URLCONF = "dpdapi.urls"
|
|
||||||
|
ROOT_URLCONF = "dpdapisite.urls"
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
@@ -54,25 +69,40 @@ TEMPLATES = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
WSGI_APPLICATION = "dpdapi.wsgi.application"
|
|
||||||
|
WSGI_APPLICATION = "dpdapisite.wsgi.application"
|
||||||
|
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/stable/ref/settings/#databases
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
"default": {
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
"NAME": os.path.join(BASE_DIR, "db.sqlite3"),
|
"NAME": BASE_DIR / "db.sqlite3",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/stable/topics/i18n/
|
||||||
LANGUAGE_CODE = "en-us"
|
LANGUAGE_CODE = "en-us"
|
||||||
TIME_ZONE = "Europe/Oslo"
|
TIME_ZONE = "Europe/Oslo"
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
USE_L10N = True
|
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/stable/howto/static-files/
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
REST_FRAMEWORK = {
|
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
# Postfix and Dovecot read these tables directly; keep the existing integer id columns.
|
||||||
"rest_framework.authentication.BasicAuthentication",
|
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||||
"rest_framework.authentication.SessionAuthentication",
|
STATIC_ROOT = BASE_DIR / "static"
|
||||||
)
|
STORAGES = {
|
||||||
|
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||||
|
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"disable_existing_loggers": False,
|
"disable_existing_loggers": False,
|
||||||
@@ -93,14 +123,5 @@ LOGGING = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
SENTRY_DSN = os.getenv("SENTRY_DSN", "https://c7e6a47844484c0a81a98e10e48c1b71@sentry.neuf.no/8")
|
with suppress(ImportError):
|
||||||
if SENTRY_DSN:
|
from .local_settings import * # noqa: F403
|
||||||
SENTRY_ENVIRONMENT = os.getenv("SENTRY_ENVIRONMENT", "production")
|
|
||||||
sentry_sdk.init(dsn=SENTRY_DSN, environment=SENTRY_ENVIRONMENT, integrations=[DjangoIntegration()])
|
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
|
||||||
|
|
||||||
try:
|
|
||||||
from .local import * # noqa
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path
|
||||||
|
from django.views.generic import RedirectView
|
||||||
|
|
||||||
|
from dpdapi.api import api
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", RedirectView.as_view(pattern_name="api:openapi-view")),
|
||||||
|
path("api/", api.urls),
|
||||||
|
path("admin/", admin.site.urls),
|
||||||
|
]
|
||||||
@@ -2,6 +2,6 @@ import os
|
|||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dpdapi.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dpdapisite.settings")
|
||||||
|
|
||||||
application = get_wsgi_application()
|
application = get_wsgi_application()
|
||||||
Vendored
-22
@@ -1,22 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
from fabric import Connection
|
|
||||||
from invoke import task
|
|
||||||
|
|
||||||
|
|
||||||
@task()
|
|
||||||
def deploy(c):
|
|
||||||
"""Make sure proxy_user is set to your neuf username."""
|
|
||||||
project_path = '/var/www/neuf.no/django-postfix-dovecot-api'
|
|
||||||
proxy_user = os.getenv('DEPLOY_USER', os.getenv('USER'))
|
|
||||||
|
|
||||||
c = Connection(host='gitdeploy@lynx.neuf.no', gateway=Connection('login.neuf.no', user=proxy_user))
|
|
||||||
|
|
||||||
with c.cd(project_path), c.prefix('source {}/venv/bin/activate'.format(project_path)):
|
|
||||||
c.run('git pull') # Get source
|
|
||||||
c.run('pip install -r requirements.txt') # install deps in virtualenv
|
|
||||||
c.run('umask 022; python manage.py collectstatic --noinput') # Collect static
|
|
||||||
c.run('python manage.py migrate') # Run DB migrations
|
|
||||||
|
|
||||||
# Reload mxapi.neuf.no
|
|
||||||
c.sudo('/usr/bin/supervisorctl pid mxapi.neuf.no | xargs kill -HUP', shell=False)
|
|
||||||
@@ -3,7 +3,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dpdapi.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dpdapisite.settings")
|
||||||
|
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[tools]
|
||||||
|
python = "3.14"
|
||||||
|
uv = "latest"
|
||||||
|
prek = "latest"
|
||||||
|
|
||||||
|
[env]
|
||||||
|
_.python.venv = { path = ".venv", create = false }
|
||||||
Generated
-991
@@ -1,991 +0,0 @@
|
|||||||
[[package]]
|
|
||||||
name = "asgiref"
|
|
||||||
version = "3.5.2"
|
|
||||||
description = "ASGI specs, helper code, and adapters"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "atomicwrites"
|
|
||||||
version = "1.4.1"
|
|
||||||
description = "Atomic file writes."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "attrs"
|
|
||||||
version = "22.1.0"
|
|
||||||
description = "Classes Without Boilerplate"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.5"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
|
|
||||||
docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
|
|
||||||
tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
|
|
||||||
tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "cloudpickle"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bcrypt"
|
|
||||||
version = "3.2.2"
|
|
||||||
description = "Modern password hashing for your software and your servers"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
cffi = ">=1.1"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
tests = ["pytest (>=3.2.1,!=3.3.0)"]
|
|
||||||
typecheck = ["mypy"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "black"
|
|
||||||
version = "22.6.0"
|
|
||||||
description = "The uncompromising code formatter."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6.2"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
click = ">=8.0.0"
|
|
||||||
mypy-extensions = ">=0.4.3"
|
|
||||||
pathspec = ">=0.9.0"
|
|
||||||
platformdirs = ">=2"
|
|
||||||
tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""}
|
|
||||||
typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
colorama = ["colorama (>=0.4.3)"]
|
|
||||||
d = ["aiohttp (>=3.7.4)"]
|
|
||||||
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
|
|
||||||
uvloop = ["uvloop (>=0.15.2)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "certifi"
|
|
||||||
version = "2022.6.15"
|
|
||||||
description = "Python package for providing Mozilla's CA Bundle."
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cffi"
|
|
||||||
version = "1.15.1"
|
|
||||||
description = "Foreign Function Interface for Python calling C code."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
pycparser = "*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cfgv"
|
|
||||||
version = "3.3.1"
|
|
||||||
description = "Validate configuration and produce human readable error messages."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6.1"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "charset-normalizer"
|
|
||||||
version = "2.1.0"
|
|
||||||
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6.0"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
unicode_backport = ["unicodedata2"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "click"
|
|
||||||
version = "8.1.3"
|
|
||||||
description = "Composable command line interface toolkit"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
colorama = {version = "*", markers = "platform_system == \"Windows\""}
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "codecov"
|
|
||||||
version = "2.1.12"
|
|
||||||
description = "Hosted coverage reports for GitHub, Bitbucket and Gitlab"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
coverage = "*"
|
|
||||||
requests = ">=2.7.9"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "colorama"
|
|
||||||
version = "0.4.5"
|
|
||||||
description = "Cross-platform colored terminal text."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "coverage"
|
|
||||||
version = "6.4.3"
|
|
||||||
description = "Code coverage measurement for Python"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
toml = ["tomli"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cryptography"
|
|
||||||
version = "37.0.4"
|
|
||||||
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
cffi = ">=1.12"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"]
|
|
||||||
docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"]
|
|
||||||
pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"]
|
|
||||||
sdist = ["setuptools_rust (>=0.11.4)"]
|
|
||||||
ssh = ["bcrypt (>=3.1.5)"]
|
|
||||||
test = ["pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "distlib"
|
|
||||||
version = "0.3.5"
|
|
||||||
description = "Distribution utilities"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "django"
|
|
||||||
version = "3.2.15"
|
|
||||||
description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design."
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
asgiref = ">=3.3.2,<4"
|
|
||||||
pytz = "*"
|
|
||||||
sqlparse = ">=0.2.2"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
argon2 = ["argon2-cffi (>=19.1.0)"]
|
|
||||||
bcrypt = ["bcrypt"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "django-extensions"
|
|
||||||
version = "3.2.0"
|
|
||||||
description = "Extensions for Django"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
Django = ">=3.2"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "django-filter"
|
|
||||||
version = "22.1"
|
|
||||||
description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically."
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
Django = ">=3.2"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "django-upgrade"
|
|
||||||
version = "1.7.0"
|
|
||||||
description = "Automatically upgrade your Django projects."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.8"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
tokenize-rt = ">=4.1.0"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "djangorestframework"
|
|
||||||
version = "3.13.1"
|
|
||||||
description = "Web APIs for Django, made easy."
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
django = ">=2.2"
|
|
||||||
pytz = "*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fabric"
|
|
||||||
version = "2.7.1"
|
|
||||||
description = "High level SSH command execution"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
invoke = ">=1.3,<2.0"
|
|
||||||
paramiko = ">=2.4"
|
|
||||||
pathlib2 = "*"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
pytest = ["mock (>=2.0.0,<3.0)", "pytest (>=3.2.5,<4.0)"]
|
|
||||||
testing = ["mock (>=2.0.0,<3.0)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "filelock"
|
|
||||||
version = "3.7.1"
|
|
||||||
description = "A platform independent file lock."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"]
|
|
||||||
testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gunicorn"
|
|
||||||
version = "20.1.0"
|
|
||||||
description = "WSGI HTTP Server for UNIX"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.5"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
eventlet = ["eventlet (>=0.24.1)"]
|
|
||||||
gevent = ["gevent (>=1.4.0)"]
|
|
||||||
setproctitle = ["setproctitle"]
|
|
||||||
tornado = ["tornado (>=0.2)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "identify"
|
|
||||||
version = "2.5.3"
|
|
||||||
description = "File identification library for Python"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
license = ["ukkonen"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "idna"
|
|
||||||
version = "3.3"
|
|
||||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.5"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "iniconfig"
|
|
||||||
version = "1.1.1"
|
|
||||||
description = "iniconfig: brain-dead simple config-ini parsing"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "invoke"
|
|
||||||
version = "1.7.1"
|
|
||||||
description = "Pythonic task execution"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "isort"
|
|
||||||
version = "5.10.1"
|
|
||||||
description = "A Python utility / library to sort Python imports."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6.1,<4.0"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
pipfile_deprecated_finder = ["pipreqs", "requirementslib"]
|
|
||||||
requirements_deprecated_finder = ["pipreqs", "pip-api"]
|
|
||||||
colors = ["colorama (>=0.4.3,<0.5.0)"]
|
|
||||||
plugins = ["setuptools"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "libcst"
|
|
||||||
version = "0.4.7"
|
|
||||||
description = "A concrete syntax tree with AST-like properties for Python 3.5, 3.6, 3.7, 3.8, 3.9, and 3.10 programs."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
pyyaml = ">=5.2"
|
|
||||||
typing-extensions = ">=3.7.4.2"
|
|
||||||
typing-inspect = ">=0.4.0"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
dev = ["black (==22.3.0)", "coverage (>=4.5.4)", "fixit (==0.1.1)", "flake8 (>=3.7.8)", "hypothesis (>=4.36.0)", "hypothesmith (>=0.0.4)", "jupyter (>=1.0.0)", "maturin (>=0.8.3,<0.9)", "nbsphinx (>=0.4.2)", "prompt-toolkit (>=2.0.9)", "setuptools-scm (>=6.0.1)", "sphinx-rtd-theme (>=0.4.3)", "ufmt (==1.3)", "usort (==1.0.0rc1)", "setuptools-rust (>=0.12.1)", "slotscheck (>=0.7.1)", "jinja2 (==3.0.3)", "pyre-check (==0.9.9)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mypy-extensions"
|
|
||||||
version = "0.4.3"
|
|
||||||
description = "Experimental type system extensions for programs checked with the mypy typechecker."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mysqlclient"
|
|
||||||
version = "2.1.1"
|
|
||||||
description = "Python interface to MySQL"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.5"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "nodeenv"
|
|
||||||
version = "1.7.0"
|
|
||||||
description = "Node.js virtual environment builder"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "packaging"
|
|
||||||
version = "21.3"
|
|
||||||
description = "Core utilities for Python packages"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "paramiko"
|
|
||||||
version = "2.11.0"
|
|
||||||
description = "SSH2 protocol library"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
bcrypt = ">=3.1.3"
|
|
||||||
cryptography = ">=2.5"
|
|
||||||
pynacl = ">=1.0.1"
|
|
||||||
six = "*"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
all = ["pyasn1 (>=0.1.7)", "pynacl (>=1.0.1)", "bcrypt (>=3.1.3)", "invoke (>=1.3)", "gssapi (>=1.4.1)", "pywin32 (>=2.1.8)"]
|
|
||||||
ed25519 = ["pynacl (>=1.0.1)", "bcrypt (>=3.1.3)"]
|
|
||||||
gssapi = ["pyasn1 (>=0.1.7)", "gssapi (>=1.4.1)", "pywin32 (>=2.1.8)"]
|
|
||||||
invoke = ["invoke (>=1.3)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "passlib"
|
|
||||||
version = "1.7.4"
|
|
||||||
description = "comprehensive password hashing framework supporting over 30 schemes"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
argon2 = ["argon2-cffi (>=18.2.0)"]
|
|
||||||
bcrypt = ["bcrypt (>=3.1.0)"]
|
|
||||||
build_docs = ["sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)", "cloud-sptheme (>=1.10.1)"]
|
|
||||||
totp = ["cryptography"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pathlib2"
|
|
||||||
version = "2.3.7.post1"
|
|
||||||
description = "Object-oriented filesystem paths"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
six = "*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pathspec"
|
|
||||||
version = "0.9.0"
|
|
||||||
description = "Utility library for gitignore style pattern matching of file paths."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "platformdirs"
|
|
||||||
version = "2.5.2"
|
|
||||||
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"]
|
|
||||||
test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pluggy"
|
|
||||||
version = "1.0.0"
|
|
||||||
description = "plugin and hook calling mechanisms for python"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
dev = ["pre-commit", "tox"]
|
|
||||||
testing = ["pytest", "pytest-benchmark"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pre-commit"
|
|
||||||
version = "2.20.0"
|
|
||||||
description = "A framework for managing and maintaining multi-language pre-commit hooks."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
cfgv = ">=2.0.0"
|
|
||||||
identify = ">=1.0.0"
|
|
||||||
nodeenv = ">=0.11.1"
|
|
||||||
pyyaml = ">=5.1"
|
|
||||||
toml = "*"
|
|
||||||
virtualenv = ">=20.0.8"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "py"
|
|
||||||
version = "1.11.0"
|
|
||||||
description = "library with cross-python path, ini-parsing, io, code, log facilities"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pycln"
|
|
||||||
version = "2.1.1"
|
|
||||||
description = "A formatter for finding and removing unused import statements."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6.2,<4"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
libcst = ">=0.3.10,<0.5.0"
|
|
||||||
pathspec = ">=0.9.0,<0.10.0"
|
|
||||||
pyyaml = ">=5.3.1,<7.0.0"
|
|
||||||
tomlkit = ">=0.11.1,<0.12.0"
|
|
||||||
typer = ">=0.4.1,<0.7.0"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pycparser"
|
|
||||||
version = "2.21"
|
|
||||||
description = "C parser in Python"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pynacl"
|
|
||||||
version = "1.5.0"
|
|
||||||
description = "Python binding to the Networking and Cryptography (NaCl) library"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
cffi = ">=1.4.1"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"]
|
|
||||||
tests = ["pytest (>=3.2.1,!=3.3.0)", "hypothesis (>=3.27.0)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pyparsing"
|
|
||||||
version = "3.0.9"
|
|
||||||
description = "pyparsing module - Classes and methods to define and execute parsing grammars"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6.8"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
diagrams = ["railroad-diagrams", "jinja2"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pytest"
|
|
||||||
version = "7.1.2"
|
|
||||||
description = "pytest: simple powerful testing with Python"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
|
|
||||||
attrs = ">=19.2.0"
|
|
||||||
colorama = {version = "*", markers = "sys_platform == \"win32\""}
|
|
||||||
iniconfig = "*"
|
|
||||||
packaging = "*"
|
|
||||||
pluggy = ">=0.12,<2.0"
|
|
||||||
py = ">=1.8.2"
|
|
||||||
tomli = ">=1.0.0"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pytest-cov"
|
|
||||||
version = "3.0.0"
|
|
||||||
description = "Pytest plugin for measuring coverage."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
coverage = {version = ">=5.2.1", extras = ["toml"]}
|
|
||||||
pytest = ">=4.6"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pytest-django"
|
|
||||||
version = "4.5.2"
|
|
||||||
description = "A Django plugin for pytest."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.5"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
pytest = ">=5.4.0"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
docs = ["sphinx", "sphinx-rtd-theme"]
|
|
||||||
testing = ["django", "django-configurations (>=2.0)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pytz"
|
|
||||||
version = "2022.1"
|
|
||||||
description = "World timezone definitions, modern and historical"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pyupgrade"
|
|
||||||
version = "2.37.3"
|
|
||||||
description = "A tool to automatically upgrade syntax for newer versions."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
tokenize-rt = ">=3.2.0"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pyyaml"
|
|
||||||
version = "6.0"
|
|
||||||
description = "YAML parser and emitter for Python"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "requests"
|
|
||||||
version = "2.28.1"
|
|
||||||
description = "Python HTTP for Humans."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7, <4"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
certifi = ">=2017.4.17"
|
|
||||||
charset-normalizer = ">=2,<3"
|
|
||||||
idna = ">=2.5,<4"
|
|
||||||
urllib3 = ">=1.21.1,<1.27"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
|
|
||||||
use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sentry-sdk"
|
|
||||||
version = "1.9.2"
|
|
||||||
description = "Python client for Sentry (https://sentry.io)"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
certifi = "*"
|
|
||||||
urllib3 = [
|
|
||||||
{version = ">=1.26.9", markers = "python_version >= \"3.5\""},
|
|
||||||
{version = ">=1.26.11", markers = "python_version >= \"3.6\""},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
aiohttp = ["aiohttp (>=3.5)"]
|
|
||||||
beam = ["apache-beam (>=2.12)"]
|
|
||||||
bottle = ["bottle (>=0.12.13)"]
|
|
||||||
celery = ["celery (>=3)"]
|
|
||||||
chalice = ["chalice (>=1.16.0)"]
|
|
||||||
django = ["django (>=1.8)"]
|
|
||||||
falcon = ["falcon (>=1.4)"]
|
|
||||||
fastapi = ["fastapi (>=0.79.0)"]
|
|
||||||
flask = ["flask (>=0.11)", "blinker (>=1.1)"]
|
|
||||||
httpx = ["httpx (>=0.16.0)"]
|
|
||||||
pure_eval = ["pure-eval", "executing", "asttokens"]
|
|
||||||
pyspark = ["pyspark (>=2.4.4)"]
|
|
||||||
quart = ["quart (>=0.16.1)", "blinker (>=1.1)"]
|
|
||||||
rq = ["rq (>=0.6)"]
|
|
||||||
sanic = ["sanic (>=0.8)"]
|
|
||||||
sqlalchemy = ["sqlalchemy (>=1.2)"]
|
|
||||||
starlette = ["starlette (>=0.19.1)"]
|
|
||||||
tornado = ["tornado (>=5)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "six"
|
|
||||||
version = "1.16.0"
|
|
||||||
description = "Python 2 and 3 compatibility utilities"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sqlparse"
|
|
||||||
version = "0.4.2"
|
|
||||||
description = "A non-validating SQL parser."
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.5"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokenize-rt"
|
|
||||||
version = "4.2.1"
|
|
||||||
description = "A wrapper around the stdlib `tokenize` which roundtrips."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6.1"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "toml"
|
|
||||||
version = "0.10.2"
|
|
||||||
description = "Python Library for Tom's Obvious, Minimal Language"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tomli"
|
|
||||||
version = "2.0.1"
|
|
||||||
description = "A lil' TOML parser"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tomlkit"
|
|
||||||
version = "0.11.1"
|
|
||||||
description = "Style preserving TOML library"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6,<4.0"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "typer"
|
|
||||||
version = "0.6.1"
|
|
||||||
description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
click = ">=7.1.1,<9.0.0"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
all = ["colorama (>=0.4.3,<0.5.0)", "shellingham (>=1.3.0,<2.0.0)", "rich (>=10.11.0,<13.0.0)"]
|
|
||||||
dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "pre-commit (>=2.17.0,<3.0.0)"]
|
|
||||||
doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)"]
|
|
||||||
test = ["shellingham (>=1.3.0,<2.0.0)", "pytest (>=4.4.0,<5.4.0)", "pytest-cov (>=2.10.0,<3.0.0)", "coverage (>=5.2,<6.0)", "pytest-xdist (>=1.32.0,<2.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "mypy (==0.910)", "black (>=22.3.0,<23.0.0)", "isort (>=5.0.6,<6.0.0)", "rich (>=10.11.0,<13.0.0)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "typing-extensions"
|
|
||||||
version = "4.3.0"
|
|
||||||
description = "Backported and Experimental Type Hints for Python 3.7+"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "typing-inspect"
|
|
||||||
version = "0.7.1"
|
|
||||||
description = "Runtime inspection utilities for typing module."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
mypy-extensions = ">=0.3.0"
|
|
||||||
typing-extensions = ">=3.7.4"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "urllib3"
|
|
||||||
version = "1.26.11"
|
|
||||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"]
|
|
||||||
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
|
|
||||||
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "virtualenv"
|
|
||||||
version = "20.16.3"
|
|
||||||
description = "Virtual Python Environment builder"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
distlib = ">=0.3.5,<1"
|
|
||||||
filelock = ">=3.4.1,<4"
|
|
||||||
platformdirs = ">=2.4,<3"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
docs = ["proselint (>=0.13)", "sphinx (>=5.1.1)", "sphinx-argparse (>=0.3.1)", "sphinx-rtd-theme (>=1)", "towncrier (>=21.9)"]
|
|
||||||
testing = ["coverage (>=6.2)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=21.3)", "pytest (>=7.0.1)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.6.1)", "pytest-randomly (>=3.10.3)", "pytest-timeout (>=2.1)"]
|
|
||||||
|
|
||||||
[metadata]
|
|
||||||
lock-version = "1.1"
|
|
||||||
python-versions = "^3.8"
|
|
||||||
content-hash = "c0829f4c7197c9f5658a2b3ea22927240b0e53c0948a27d109314d338b808c1d"
|
|
||||||
|
|
||||||
[metadata.files]
|
|
||||||
asgiref = [
|
|
||||||
{file = "asgiref-3.5.2-py3-none-any.whl", hash = "sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4"},
|
|
||||||
{file = "asgiref-3.5.2.tar.gz", hash = "sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424"},
|
|
||||||
]
|
|
||||||
atomicwrites = []
|
|
||||||
attrs = []
|
|
||||||
bcrypt = []
|
|
||||||
black = []
|
|
||||||
certifi = [
|
|
||||||
{file = "certifi-2022.6.15-py3-none-any.whl", hash = "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"},
|
|
||||||
{file = "certifi-2022.6.15.tar.gz", hash = "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d"},
|
|
||||||
]
|
|
||||||
cffi = []
|
|
||||||
cfgv = [
|
|
||||||
{file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"},
|
|
||||||
{file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"},
|
|
||||||
]
|
|
||||||
charset-normalizer = []
|
|
||||||
click = [
|
|
||||||
{file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
|
|
||||||
{file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
|
|
||||||
]
|
|
||||||
codecov = [
|
|
||||||
{file = "codecov-2.1.12-py2.py3-none-any.whl", hash = "sha256:585dc217dc3d8185198ceb402f85d5cb5dbfa0c5f350a5abcdf9e347776a5b47"},
|
|
||||||
{file = "codecov-2.1.12-py3.8.egg", hash = "sha256:782a8e5352f22593cbc5427a35320b99490eb24d9dcfa2155fd99d2b75cfb635"},
|
|
||||||
{file = "codecov-2.1.12.tar.gz", hash = "sha256:a0da46bb5025426da895af90938def8ee12d37fcbcbbbc15b6dc64cf7ebc51c1"},
|
|
||||||
]
|
|
||||||
colorama = [
|
|
||||||
{file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"},
|
|
||||||
{file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"},
|
|
||||||
]
|
|
||||||
coverage = []
|
|
||||||
cryptography = []
|
|
||||||
distlib = []
|
|
||||||
django = []
|
|
||||||
django-extensions = []
|
|
||||||
django-filter = []
|
|
||||||
django-upgrade = []
|
|
||||||
djangorestframework = [
|
|
||||||
{file = "djangorestframework-3.13.1-py3-none-any.whl", hash = "sha256:24c4bf58ed7e85d1fe4ba250ab2da926d263cd57d64b03e8dcef0ac683f8b1aa"},
|
|
||||||
{file = "djangorestframework-3.13.1.tar.gz", hash = "sha256:0c33407ce23acc68eca2a6e46424b008c9c02eceb8cf18581921d0092bc1f2ee"},
|
|
||||||
]
|
|
||||||
fabric = []
|
|
||||||
filelock = [
|
|
||||||
{file = "filelock-3.7.1-py3-none-any.whl", hash = "sha256:37def7b658813cda163b56fc564cdc75e86d338246458c4c28ae84cabefa2404"},
|
|
||||||
{file = "filelock-3.7.1.tar.gz", hash = "sha256:3a0fd85166ad9dbab54c9aec96737b744106dc5f15c0b09a6744a445299fcf04"},
|
|
||||||
]
|
|
||||||
gunicorn = [
|
|
||||||
{file = "gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e"},
|
|
||||||
{file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"},
|
|
||||||
]
|
|
||||||
identify = []
|
|
||||||
idna = [
|
|
||||||
{file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
|
|
||||||
{file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
|
|
||||||
]
|
|
||||||
iniconfig = [
|
|
||||||
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
|
|
||||||
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
|
|
||||||
]
|
|
||||||
invoke = []
|
|
||||||
isort = [
|
|
||||||
{file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"},
|
|
||||||
{file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"},
|
|
||||||
]
|
|
||||||
libcst = []
|
|
||||||
mypy-extensions = [
|
|
||||||
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
|
|
||||||
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
|
|
||||||
]
|
|
||||||
mysqlclient = []
|
|
||||||
nodeenv = []
|
|
||||||
packaging = [
|
|
||||||
{file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
|
|
||||||
{file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
|
|
||||||
]
|
|
||||||
paramiko = []
|
|
||||||
passlib = [
|
|
||||||
{file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"},
|
|
||||||
{file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"},
|
|
||||||
]
|
|
||||||
pathlib2 = [
|
|
||||||
{file = "pathlib2-2.3.7.post1-py2.py3-none-any.whl", hash = "sha256:5266a0fd000452f1b3467d782f079a4343c63aaa119221fbdc4e39577489ca5b"},
|
|
||||||
{file = "pathlib2-2.3.7.post1.tar.gz", hash = "sha256:9fe0edad898b83c0c3e199c842b27ed216645d2e177757b2dd67384d4113c641"},
|
|
||||||
]
|
|
||||||
pathspec = [
|
|
||||||
{file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
|
|
||||||
{file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
|
|
||||||
]
|
|
||||||
platformdirs = [
|
|
||||||
{file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"},
|
|
||||||
{file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"},
|
|
||||||
]
|
|
||||||
pluggy = [
|
|
||||||
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
|
|
||||||
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
|
|
||||||
]
|
|
||||||
pre-commit = []
|
|
||||||
py = [
|
|
||||||
{file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
|
|
||||||
{file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
|
|
||||||
]
|
|
||||||
pycln = []
|
|
||||||
pycparser = [
|
|
||||||
{file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
|
|
||||||
{file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
|
|
||||||
]
|
|
||||||
pynacl = [
|
|
||||||
{file = "PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1"},
|
|
||||||
{file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92"},
|
|
||||||
{file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394"},
|
|
||||||
{file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d"},
|
|
||||||
{file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858"},
|
|
||||||
{file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b"},
|
|
||||||
{file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff"},
|
|
||||||
{file = "PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543"},
|
|
||||||
{file = "PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93"},
|
|
||||||
{file = "PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba"},
|
|
||||||
]
|
|
||||||
pyparsing = [
|
|
||||||
{file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
|
|
||||||
{file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
|
|
||||||
]
|
|
||||||
pytest = [
|
|
||||||
{file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"},
|
|
||||||
{file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"},
|
|
||||||
]
|
|
||||||
pytest-cov = []
|
|
||||||
pytest-django = [
|
|
||||||
{file = "pytest-django-4.5.2.tar.gz", hash = "sha256:d9076f759bb7c36939dbdd5ae6633c18edfc2902d1a69fdbefd2426b970ce6c2"},
|
|
||||||
{file = "pytest_django-4.5.2-py3-none-any.whl", hash = "sha256:c60834861933773109334fe5a53e83d1ef4828f2203a1d6a0fa9972f4f75ab3e"},
|
|
||||||
]
|
|
||||||
pytz = [
|
|
||||||
{file = "pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"},
|
|
||||||
{file = "pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7"},
|
|
||||||
]
|
|
||||||
pyupgrade = []
|
|
||||||
pyyaml = [
|
|
||||||
{file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"},
|
|
||||||
{file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"},
|
|
||||||
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"},
|
|
||||||
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"},
|
|
||||||
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"},
|
|
||||||
{file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"},
|
|
||||||
{file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"},
|
|
||||||
{file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"},
|
|
||||||
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"},
|
|
||||||
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"},
|
|
||||||
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"},
|
|
||||||
{file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"},
|
|
||||||
{file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"},
|
|
||||||
{file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"},
|
|
||||||
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"},
|
|
||||||
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"},
|
|
||||||
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"},
|
|
||||||
{file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"},
|
|
||||||
{file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"},
|
|
||||||
{file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"},
|
|
||||||
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"},
|
|
||||||
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"},
|
|
||||||
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"},
|
|
||||||
{file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"},
|
|
||||||
{file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"},
|
|
||||||
{file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"},
|
|
||||||
{file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"},
|
|
||||||
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"},
|
|
||||||
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"},
|
|
||||||
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"},
|
|
||||||
{file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"},
|
|
||||||
{file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"},
|
|
||||||
{file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
|
|
||||||
]
|
|
||||||
requests = []
|
|
||||||
sentry-sdk = []
|
|
||||||
six = [
|
|
||||||
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
|
|
||||||
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
|
|
||||||
]
|
|
||||||
sqlparse = [
|
|
||||||
{file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"},
|
|
||||||
{file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"},
|
|
||||||
]
|
|
||||||
tokenize-rt = [
|
|
||||||
{file = "tokenize_rt-4.2.1-py2.py3-none-any.whl", hash = "sha256:08a27fa032a81cf45e8858d0ac706004fcd523e8463415ddf1442be38e204ea8"},
|
|
||||||
{file = "tokenize_rt-4.2.1.tar.gz", hash = "sha256:0d4f69026fed520f8a1e0103aa36c406ef4661417f20ca643f913e33531b3b94"},
|
|
||||||
]
|
|
||||||
toml = [
|
|
||||||
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
|
|
||||||
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
|
|
||||||
]
|
|
||||||
tomli = [
|
|
||||||
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
|
|
||||||
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
|
|
||||||
]
|
|
||||||
tomlkit = []
|
|
||||||
typer = []
|
|
||||||
typing-extensions = []
|
|
||||||
typing-inspect = [
|
|
||||||
{file = "typing_inspect-0.7.1-py2-none-any.whl", hash = "sha256:b1f56c0783ef0f25fb064a01be6e5407e54cf4a4bf4f3ba3fe51e0bd6dcea9e5"},
|
|
||||||
{file = "typing_inspect-0.7.1-py3-none-any.whl", hash = "sha256:3cd7d4563e997719a710a3bfe7ffb544c6b72069b6812a02e9b414a8fa3aaa6b"},
|
|
||||||
{file = "typing_inspect-0.7.1.tar.gz", hash = "sha256:047d4097d9b17f46531bf6f014356111a1b6fb821a24fe7ac909853ca2a782aa"},
|
|
||||||
]
|
|
||||||
urllib3 = []
|
|
||||||
virtualenv = []
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#:schema https://www.schemastore.org/prek.json
|
||||||
|
# Git hooks, run by prek (https://prek.j178.dev). `prek install` once per clone; CI runs
|
||||||
|
# them all (.gitea/workflows/ci.yaml).
|
||||||
|
|
||||||
|
[[repos]]
|
||||||
|
repo = "builtin"
|
||||||
|
hooks = [
|
||||||
|
{ id = "trailing-whitespace" },
|
||||||
|
{ id = "end-of-file-fixer" },
|
||||||
|
{ id = "mixed-line-ending", args = ["--fix", "lf"] },
|
||||||
|
{ id = "fix-byte-order-marker" },
|
||||||
|
{ id = "check-added-large-files" },
|
||||||
|
{ id = "check-case-conflict" },
|
||||||
|
{ id = "check-merge-conflict" },
|
||||||
|
{ id = "check-executables-have-shebangs" },
|
||||||
|
{ id = "check-shebang-scripts-are-executable" },
|
||||||
|
{ id = "check-symlinks" },
|
||||||
|
{ id = "check-toml" },
|
||||||
|
{ id = "check-yaml" },
|
||||||
|
{ id = "detect-private-key" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[repos]]
|
||||||
|
repo = "https://github.com/astral-sh/ruff-pre-commit"
|
||||||
|
rev = "v0.17.3" # keep in sync with ruff in uv.lock
|
||||||
|
hooks = [
|
||||||
|
{ id = "ruff-check", args = ["--fix"] },
|
||||||
|
{ id = "ruff-format" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[repos]]
|
||||||
|
repo = "https://github.com/adamchainz/django-upgrade"
|
||||||
|
rev = "1.33.0"
|
||||||
|
hooks = [{ id = "django-upgrade", args = ["--target-version", "5.2"] }]
|
||||||
|
|
||||||
|
[[repos]]
|
||||||
|
repo = "https://github.com/astral-sh/uv-pre-commit"
|
||||||
|
rev = "0.13.6"
|
||||||
|
hooks = [{ id = "uv-lock" }] # uv.lock stays in sync with pyproject.toml
|
||||||
+53
-41
@@ -1,47 +1,59 @@
|
|||||||
[tool.pytest.ini_options]
|
[project]
|
||||||
DJANGO_SETTINGS_MODULE = "dpdapi.settings"
|
|
||||||
# FIXME: https://github.com/django-extensions/django-extensions/pull/1741
|
|
||||||
filterwarnings = "ignore::PendingDeprecationWarning:django.apps.registry.*"
|
|
||||||
|
|
||||||
[tool.black]
|
|
||||||
line-length = 120
|
|
||||||
target_version = ['py38']
|
|
||||||
|
|
||||||
[tool.isort]
|
|
||||||
profile = "black"
|
|
||||||
line_length = 120
|
|
||||||
|
|
||||||
[tool.poetry]
|
|
||||||
name = "django-postfix-dovecot-api"
|
name = "django-postfix-dovecot-api"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = ""
|
description = "REST API for managing Postfix/Dovecot virtual domains, users and aliases"
|
||||||
authors = ["EDB <edb@neuf.no>"]
|
readme = "README.md"
|
||||||
|
requires-python = "~=3.14.0"
|
||||||
|
dependencies = [
|
||||||
|
"django>=5.2,<6",
|
||||||
|
"django-extensions>=4.1",
|
||||||
|
"django-ninja>=1.7.3",
|
||||||
|
"libpass>=1.9.4",
|
||||||
|
"sentry-sdk>=2.73.0",
|
||||||
|
"whitenoise>=6.12.0",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[dependency-groups]
|
||||||
python = "^3.8"
|
dev = [
|
||||||
|
"pytest>=9.2.0",
|
||||||
|
"pytest-cov>=7.1.0",
|
||||||
|
"pytest-django>=4.14.0",
|
||||||
|
"ruff>=0.17.3",
|
||||||
|
]
|
||||||
|
prod = [
|
||||||
|
"gunicorn>=26.2.0",
|
||||||
|
"mysqlclient>=2.3.0",
|
||||||
|
]
|
||||||
|
|
||||||
Django="3.2.*"
|
[tool.uv]
|
||||||
django-extensions="3.2.*"
|
package = false
|
||||||
django-filter="22.1"
|
|
||||||
djangorestframework="3.13.*"
|
|
||||||
gunicorn="20.1.*"
|
|
||||||
mysqlclient="2.1.*"
|
|
||||||
passlib="1.7.*"
|
|
||||||
sentry-sdk="1.9.*"
|
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.pytest.ini_options]
|
||||||
black = "22.6.*"
|
DJANGO_SETTINGS_MODULE = "dpdapisite.settings"
|
||||||
codecov = "2.1.*"
|
testpaths = ["tests"]
|
||||||
django-upgrade = "^1.7.0"
|
pythonpath = ["."]
|
||||||
fabric="2.7.*"
|
filterwarnings = ["error"]
|
||||||
isort = "*"
|
addopts = "--cov --cov-report=term-missing"
|
||||||
pre-commit = "*"
|
|
||||||
pycln = "2.1.*"
|
|
||||||
pytest = "*"
|
|
||||||
pytest-cov = "^3.0.0"
|
|
||||||
pytest-django = "*"
|
|
||||||
pyupgrade = "2.37.*"
|
|
||||||
|
|
||||||
[build-system]
|
[tool.coverage.run]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
source = ["dpdapi", "dpdapisite"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
omit = ["*/migrations/*", "dpdapisite/settings.py", "dpdapisite/settings_prod.py"]
|
||||||
|
branch = true
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
fail_under = 100
|
||||||
|
show_missing = true
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 120
|
||||||
|
target-version = "py314"
|
||||||
|
extend-exclude = ["*/migrations/*"]
|
||||||
|
force-exclude = true
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"E", "W", "F", "I", "UP", "B", "A", "C4", "DJ", "S", "SIM", "PT", "PTH", "RET", "RUF", "N", "LOG", "G",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/*" = ["S101", "S105", "S106"]
|
||||||
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