refactor!: replace django rest framework with django-ninja
This commit is contained in:
6 files changed
+277
-161
No files matched your search
+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,18 +0,0 @@
|
||||
import django_filters
|
||||
from rest_framework.filters import BaseFilterBackend
|
||||
|
||||
from dpdapi.models import Alias
|
||||
|
||||
|
||||
class AliasRegexFilter(django_filters.FilterSet):
|
||||
class Meta:
|
||||
model = Alias
|
||||
fields = {
|
||||
'source': ['iregex', 'regex'],
|
||||
'destination': ['iregex', 'regex']
|
||||
}
|
||||
|
||||
|
||||
class AliasRegexFilterBackend(BaseFilterBackend):
|
||||
def filter_queryset(self, request, queryset, view):
|
||||
return AliasRegexFilter(request.query_params, queryset=queryset).qs
|
||||
@@ -1,54 +0,0 @@
|
||||
from rest_framework import serializers
|
||||
from passlib.hash import sha512_crypt
|
||||
from .models import Alias, Domain, User
|
||||
|
||||
|
||||
class AliasSerializer(serializers.ModelSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
# Workaround to accept duplicates
|
||||
# FIXME, will fail with existing alias with validated_data['active']=False
|
||||
alias, created = Alias.objects.get_or_create(**validated_data)
|
||||
return alias
|
||||
|
||||
class Meta:
|
||||
model = Alias
|
||||
validators = [] # Disable unique togheter check
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class AliasDeleteSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Alias
|
||||
validators = [] # No Unique togheter check on deletion
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class DomainSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Domain
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = '__all__'
|
||||
extra_kwargs = {'password': {'write_only': True}}
|
||||
|
||||
def _hash_password(self, raw_password):
|
||||
return sha512_crypt.hash(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
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if 'password' in validated_data:
|
||||
validated_data['password'] = self._hash_password(validated_data['password'])
|
||||
return super().update(instance, validated_data)
|
||||
@@ -1,10 +0,0 @@
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import AliasViewSet, DomainViewSet, UserViewSet
|
||||
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'aliases', AliasViewSet)
|
||||
router.register(r'domains', DomainViewSet)
|
||||
router.register(r'users', UserViewSet)
|
||||
urlpatterns = router.urls
|
||||
@@ -1,75 +0,0 @@
|
||||
import logging
|
||||
import operator
|
||||
|
||||
from functools import reduce
|
||||
from django.db.models import Q
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.mixins import ListModelMixin
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from dpdapi.filters import AliasRegexFilterBackend
|
||||
from dpdapi.models import Alias, Domain, User
|
||||
from dpdapi.serializers import AliasSerializer, AliasDeleteSerializer, DomainSerializer, UserSerializer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AliasViewSet(ListModelMixin, GenericViewSet):
|
||||
""" Aliases """
|
||||
queryset = Alias.objects.all()
|
||||
serializer_class = AliasSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
filter_backends = (DjangoFilterBackend, AliasRegexFilterBackend)
|
||||
filterset_fields = ('domain', 'domain__name',)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def create_bulk(self, request):
|
||||
serializer = AliasSerializer(data=request.data, many=True)
|
||||
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=False, methods=['delete'])
|
||||
def delete_bulk(self, request):
|
||||
serializer = AliasDeleteSerializer(data=request.data, many=True)
|
||||
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
keys = {(a['source'], a['destination'], a['domain'].pk) for a in serializer.validated_data}
|
||||
query = reduce(operator.or_, (Q(source=s, destination=d, domain=dom) for s, d, dom in keys), Q(pk__in=[]))
|
||||
aliases = Alias.objects.filter(query)
|
||||
|
||||
deleted_set = set(aliases.values_list('source', 'destination', 'domain'))
|
||||
diff = keys - 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]
|
||||
+5
-4
@@ -1,10 +1,11 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
from django.urls import path
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
from dpdapi.api import api
|
||||
|
||||
urlpatterns = [
|
||||
path("", RedirectView.as_view(pattern_name="api-root")),
|
||||
path("api/", include("dpdapi.urls")),
|
||||
path("", RedirectView.as_view(pattern_name="api:openapi-view")),
|
||||
path("api/", api.urls),
|
||||
path("admin/", admin.site.urls),
|
||||
path("api-auth/", include("rest_framework.urls", namespace="rest_framework")),
|
||||
]
|
||||
Reference in new issue
Block a user