Browse Source

refactor(api): introduce black formatting

Peter Thomassen 2 years ago
parent
commit
671ca12b96
100 changed files with 5970 additions and 2980 deletions
  1. 2 0
      README.md
  2. 2 2
      api/desecapi/apps.py
  3. 42 23
      api/desecapi/authentication.py
  4. 16 6
      api/desecapi/crypto.py
  5. 38 19
      api/desecapi/dns.py
  6. 21 11
      api/desecapi/exception_handlers.py
  7. 9 5
      api/desecapi/exceptions.py
  8. 14 10
      api/desecapi/mail_backends.py
  9. 32 18
      api/desecapi/management/commands/align-catalog-zone.py
  10. 69 29
      api/desecapi/management/commands/check-secondaries.py
  11. 46 32
      api/desecapi/management/commands/chores.py
  12. 27 17
      api/desecapi/management/commands/limit.py
  13. 44 22
      api/desecapi/management/commands/outreach-email.py
  14. 66 28
      api/desecapi/management/commands/scavenge-unused.py
  15. 28 17
      api/desecapi/management/commands/stop-abuse.py
  16. 17 14
      api/desecapi/management/commands/sync-from-pdns.py
  17. 30 19
      api/desecapi/management/commands/sync-to-pdns.py
  18. 42 13
      api/desecapi/metrics.py
  19. 371 100
      api/desecapi/migrations/0001_initial_squashed_again.py
  20. 3 3
      api/desecapi/migrations/0002_unmanaged_donations.py
  21. 3 3
      api/desecapi/migrations/0003_rr_content.py
  22. 7 4
      api/desecapi/migrations/0004_immortal_domains.py
  23. 15 4
      api/desecapi/migrations/0005_subname_validation.py
  24. 13 3
      api/desecapi/migrations/0006_cname_exclusivity.py
  25. 6 4
      api/desecapi/migrations/0007_email_citext.py
  26. 5 5
      api/desecapi/migrations/0008_token_perm_manage_tokens.py
  27. 8 4
      api/desecapi/migrations/0009_token_allowed_subnets.py
  28. 19 7
      api/desecapi/migrations/0010_token_expiration.py
  29. 11 7
      api/desecapi/migrations/0011_captcha_kind.py
  30. 15 4
      api/desecapi/migrations/0012_rrset_label_length.py
  31. 5 5
      api/desecapi/migrations/0013_user_needs_captcha.py
  32. 5 5
      api/desecapi/migrations/0014_replication.py
  33. 3 3
      api/desecapi/migrations/0015_rrset_touched_index.py
  34. 11 7
      api/desecapi/migrations/0016_default_auto_field.py
  35. 8 4
      api/desecapi/migrations/0017_alter_user_limit_domains.py
  36. 69 27
      api/desecapi/migrations/0018_tokendomainpolicy.py
  37. 6 4
      api/desecapi/migrations/0019_alter_user_is_active.py
  38. 5 5
      api/desecapi/migrations/0020_user_email_verified.py
  39. 15 5
      api/desecapi/migrations/0021_authenticatednoopuseraction.py
  40. 3 3
      api/desecapi/migrations/0022_user_outreach_preference.py
  41. 15 5
      api/desecapi/migrations/0023_authenticatedemailuseraction.py
  42. 16 6
      api/desecapi/migrations/0024_authenticatedchangeoutreachpreferenceuseraction.py
  43. 31 9
      api/desecapi/migrations/0025_alter_token_max_age_alter_token_max_unused_period.py
  44. 5 5
      api/desecapi/migrations/0026_remove_domain_replicated_and_more.py
  45. 6 6
      api/desecapi/migrations/0027_user_credentials_changed.py
  46. 19 11
      api/desecapi/models/authenticated_actions.py
  47. 16 12
      api/desecapi/models/base.py
  48. 10 10
      api/desecapi/models/captcha.py
  49. 116 54
      api/desecapi/models/domains.py
  50. 1 1
      api/desecapi/models/donation.py
  51. 91 52
      api/desecapi/models/records.py
  52. 65 31
      api/desecapi/models/tokens.py
  53. 44 30
      api/desecapi/models/users.py
  54. 12 5
      api/desecapi/pagination.py
  55. 116 55
      api/desecapi/pdns.py
  56. 177 95
      api/desecapi/pdns_change_tracker.py
  57. 18 8
      api/desecapi/permissions.py
  58. 8 6
      api/desecapi/renderers.py
  59. 87 51
      api/desecapi/serializers/authenticated_actions.py
  60. 12 6
      api/desecapi/serializers/captcha.py
  61. 83 37
      api/desecapi/serializers/domains.py
  62. 16 9
      api/desecapi/serializers/donation.py
  63. 168 93
      api/desecapi/serializers/records.py
  64. 32 13
      api/desecapi/serializers/tokens.py
  65. 33 15
      api/desecapi/serializers/users.py
  66. 3 1
      api/desecapi/signals.py
  67. 9 3
      api/desecapi/templatetags/action_extras.py
  68. 4 4
      api/desecapi/templatetags/sepa_extras.py
  69. 419 217
      api/desecapi/tests/base.py
  70. 125 51
      api/desecapi/tests/test_authentication.py
  71. 22 20
      api/desecapi/tests/test_captcha.py
  72. 46 16
      api/desecapi/tests/test_chores.py
  73. 21 11
      api/desecapi/tests/test_crypto.py
  74. 397 231
      api/desecapi/tests/test_domains.py
  75. 28 25
      api/desecapi/tests/test_donations.py
  76. 112 91
      api/desecapi/tests/test_dyndns12update.py
  77. 16 8
      api/desecapi/tests/test_limit.py
  78. 25 11
      api/desecapi/tests/test_mail_backends.py
  79. 245 125
      api/desecapi/tests/test_pdns_change_tracker.py
  80. 14 12
      api/desecapi/tests/test_replication.py
  81. 607 287
      api/desecapi/tests/test_rrsets.py
  82. 463 185
      api/desecapi/tests/test_rrsets_bulk.py
  83. 64 27
      api/desecapi/tests/test_stop_abuse.py
  84. 29 14
      api/desecapi/tests/test_throttling.py
  85. 156 61
      api/desecapi/tests/test_token_domain_policy.py
  86. 8 5
      api/desecapi/tests/test_token_policies.py
  87. 92 49
      api/desecapi/tests/test_tokens.py
  88. 303 179
      api/desecapi/tests/test_user_management.py
  89. 16 6
      api/desecapi/throttling.py
  90. 96 44
      api/desecapi/urls/version_1.py
  91. 1 1
      api/desecapi/urls/version_2.py
  92. 12 10
      api/desecapi/validators.py
  93. 92 45
      api/desecapi/views/authenticated_actions.py
  94. 13 11
      api/desecapi/views/base.py
  95. 1 1
      api/desecapi/views/captcha.py
  96. 24 16
      api/desecapi/views/domains.py
  97. 25 20
      api/desecapi/views/donation.py
  98. 70 35
      api/desecapi/views/dyndns.py
  99. 37 20
      api/desecapi/views/records.py
  100. 27 12
      api/desecapi/views/tokens.py

+ 2 - 0
README.md

@@ -383,6 +383,8 @@ While there are certainly many ways to get started hacking desec-stack, here is
 
 
     1. For PyCharm's Python Console, the environment variables of your `.env` file and `DJANGO_SETTINGS_MODULE=api.settings_quick_test` need to be configured in Settings › Build, Execution, Deployment › Console › Django Console. (Note that if you need to work with the database, you need to initialize it first by running all migrations; otherwise, the model tables will be missing from the database.)
     1. For PyCharm's Python Console, the environment variables of your `.env` file and `DJANGO_SETTINGS_MODULE=api.settings_quick_test` need to be configured in Settings › Build, Execution, Deployment › Console › Django Console. (Note that if you need to work with the database, you need to initialize it first by running all migrations; otherwise, the model tables will be missing from the database.)
 
 
+1. **Code quality.** We use [Black](https://pypi.org/project/black/) to ensure formatting consistency and minimal diffs. Before you commit Python code into the `api/` directory, please run `black api/desecapi/`.
+
 
 
 ## Debugging
 ## Debugging
 
 

+ 2 - 2
api/desecapi/apps.py

@@ -2,8 +2,8 @@ from django.apps import AppConfig as DjangoAppConfig
 
 
 
 
 class AppConfig(DjangoAppConfig):
 class AppConfig(DjangoAppConfig):
-    default_auto_field = 'django.db.models.BigAutoField'
-    name = 'desecapi'
+    default_auto_field = "django.db.models.BigAutoField"
+    name = "desecapi"
 
 
     def ready(self):
     def ready(self):
         from desecapi import signals  # connect signals
         from desecapi import signals  # connect signals

+ 42 - 23
api/desecapi/authentication.py

@@ -9,10 +9,14 @@ from rest_framework.authentication import (
     BaseAuthentication,
     BaseAuthentication,
     get_authorization_header,
     get_authorization_header,
     TokenAuthentication as RestFrameworkTokenAuthentication,
     TokenAuthentication as RestFrameworkTokenAuthentication,
-    BasicAuthentication)
+    BasicAuthentication,
+)
 
 
 from desecapi.models import Domain, Token
 from desecapi.models import Domain, Token
-from desecapi.serializers import AuthenticatedBasicUserActionSerializer, EmailPasswordSerializer
+from desecapi.serializers import (
+    AuthenticatedBasicUserActionSerializer,
+    EmailPasswordSerializer,
+)
 
 
 
 
 class DynAuthenticationMixin:
 class DynAuthenticationMixin:
@@ -20,7 +24,10 @@ class DynAuthenticationMixin:
         user, token = TokenAuthentication().authenticate_credentials(key)
         user, token = TokenAuthentication().authenticate_credentials(key)
         # Make sure username is not misleading
         # Make sure username is not misleading
         try:
         try:
-            if username in ['', user.email] or Domain.objects.filter_qname(username.lower(), owner=user).exists():
+            if (
+                username in ["", user.email]
+                or Domain.objects.filter_qname(username.lower(), owner=user).exists()
+            ):
                 return user, token
                 return user, token
         except ValueError:
         except ValueError:
             pass
             pass
@@ -34,7 +41,9 @@ class TokenAuthentication(RestFrameworkTokenAuthentication):
     # It thus exposes the failure reason when under timing attack.
     # It thus exposes the failure reason when under timing attack.
     def authenticate(self, request):
     def authenticate(self, request):
         try:
         try:
-            user, token = super().authenticate(request)  # may raise exceptions.AuthenticationFailed if token is invalid
+            user, token = super().authenticate(
+                request
+            )  # may raise exceptions.AuthenticationFailed if token is invalid
         except TypeError:  # no token given
         except TypeError:  # no token given
             return None  # unauthenticated
             return None  # unauthenticated
 
 
@@ -48,12 +57,12 @@ class TokenAuthentication(RestFrameworkTokenAuthentication):
         # In case the stack is run behind an application proxy, the address will be the proxy's address. Extracting the
         # In case the stack is run behind an application proxy, the address will be the proxy's address. Extracting the
         # real client address is currently not supported. For further information on this case, see
         # real client address is currently not supported. For further information on this case, see
         # https://www.django-rest-framework.org/api-guide/throttling/#how-clients-are-identified
         # https://www.django-rest-framework.org/api-guide/throttling/#how-clients-are-identified
-        client_ip = ip_address(request.META.get('REMOTE_ADDR'))
+        client_ip = ip_address(request.META.get("REMOTE_ADDR"))
 
 
         # This can likely be done within Postgres with django-postgres-extensions (client_ip <<= ANY allowed_subnets).
         # This can likely be done within Postgres with django-postgres-extensions (client_ip <<= ANY allowed_subnets).
         # However, the django-postgres-extensions package is unmaintained, and the GitHub repo has been archived.
         # However, the django-postgres-extensions package is unmaintained, and the GitHub repo has been archived.
         if not any(client_ip in subnet for subnet in token.allowed_subnets):
         if not any(client_ip in subnet for subnet in token.allowed_subnets):
-            raise exceptions.AuthenticationFailed('Invalid token.')
+            raise exceptions.AuthenticationFailed("Invalid token.")
 
 
         return user, token
         return user, token
 
 
@@ -65,7 +74,7 @@ class TokenAuthentication(RestFrameworkTokenAuthentication):
             return None  # unauthenticated
             return None  # unauthenticated
 
 
         if not token.is_valid:
         if not token.is_valid:
-            raise exceptions.AuthenticationFailed('Invalid token.')
+            raise exceptions.AuthenticationFailed("Invalid token.")
         token.last_used = timezone.now()
         token.last_used = timezone.now()
         token.save()
         token.save()
         return user, token
         return user, token
@@ -93,30 +102,33 @@ class BasicTokenAuthentication(BaseAuthentication, DynAuthenticationMixin):
     def authenticate(self, request):
     def authenticate(self, request):
         auth = get_authorization_header(request).split()
         auth = get_authorization_header(request).split()
 
 
-        if not auth or auth[0].lower() != b'basic':
+        if not auth or auth[0].lower() != b"basic":
             return None
             return None
 
 
         if len(auth) == 1:
         if len(auth) == 1:
-            msg = 'Invalid basic auth token header. No credentials provided.'
+            msg = "Invalid basic auth token header. No credentials provided."
             raise exceptions.AuthenticationFailed(msg)
             raise exceptions.AuthenticationFailed(msg)
         elif len(auth) > 2:
         elif len(auth) > 2:
-            msg = 'Invalid basic auth token header. Basic authentication string should not contain spaces.'
+            msg = "Invalid basic auth token header. Basic authentication string should not contain spaces."
             raise exceptions.AuthenticationFailed(msg)
             raise exceptions.AuthenticationFailed(msg)
 
 
         try:
         try:
-            username, key = base64.b64decode(auth[1]).decode(HTTP_HEADER_ENCODING).split(':')
+            username, key = (
+                base64.b64decode(auth[1]).decode(HTTP_HEADER_ENCODING).split(":")
+            )
             return self.authenticate_credentials(username, key)
             return self.authenticate_credentials(username, key)
         except Exception:
         except Exception:
             raise exceptions.AuthenticationFailed("badauth")
             raise exceptions.AuthenticationFailed("badauth")
 
 
     def authenticate_header(self, request):
     def authenticate_header(self, request):
-        return 'Basic'
+        return "Basic"
 
 
 
 
 class URLParamAuthentication(BaseAuthentication, DynAuthenticationMixin):
 class URLParamAuthentication(BaseAuthentication, DynAuthenticationMixin):
     """
     """
     Authentication against username/password as provided in URL parameters.
     Authentication against username/password as provided in URL parameters.
     """
     """
+
     model = Token
     model = Token
 
 
     def authenticate(self, request):
     def authenticate(self, request):
@@ -125,15 +137,17 @@ class URLParamAuthentication(BaseAuthentication, DynAuthenticationMixin):
         using URL parameters.  Otherwise raises `AuthenticationFailed`.
         using URL parameters.  Otherwise raises `AuthenticationFailed`.
         """
         """
 
 
-        if 'username' not in request.query_params:
-            msg = 'No username URL parameter provided.'
+        if "username" not in request.query_params:
+            msg = "No username URL parameter provided."
             raise exceptions.AuthenticationFailed(msg)
             raise exceptions.AuthenticationFailed(msg)
-        if 'password' not in request.query_params:
-            msg = 'No password URL parameter provided.'
+        if "password" not in request.query_params:
+            msg = "No password URL parameter provided."
             raise exceptions.AuthenticationFailed(msg)
             raise exceptions.AuthenticationFailed(msg)
 
 
         try:
         try:
-            return self.authenticate_credentials(request.query_params['username'], request.query_params['password'])
+            return self.authenticate_credentials(
+                request.query_params["username"], request.query_params["password"]
+            )
         except Exception:
         except Exception:
             raise exceptions.AuthenticationFailed("badauth")
             raise exceptions.AuthenticationFailed("badauth")
 
 
@@ -144,7 +158,9 @@ class EmailPasswordPayloadAuthentication(BaseAuthentication):
     def authenticate(self, request):
     def authenticate(self, request):
         serializer = EmailPasswordSerializer(data=request.data)
         serializer = EmailPasswordSerializer(data=request.data)
         serializer.is_valid(raise_exception=True)
         serializer.is_valid(raise_exception=True)
-        return self.authenticate_credentials(serializer.data['email'], serializer.data['password'], request)
+        return self.authenticate_credentials(
+            serializer.data["email"], serializer.data["password"], request
+        )
 
 
 
 
 class AuthenticatedBasicUserActionAuthentication(BaseAuthentication):
 class AuthenticatedBasicUserActionAuthentication(BaseAuthentication):
@@ -152,26 +168,29 @@ class AuthenticatedBasicUserActionAuthentication(BaseAuthentication):
     Authenticates a request based on whether the serializer determines the validity of the given verification code
     Authenticates a request based on whether the serializer determines the validity of the given verification code
     based on the view's 'code' kwarg and the view serializer's code validity period.
     based on the view's 'code' kwarg and the view serializer's code validity period.
     """
     """
+
     def authenticate(self, request):
     def authenticate(self, request):
-        view = request.parser_context['view']
+        view = request.parser_context["view"]
         return self.authenticate_credentials(view.get_serializer_context())
         return self.authenticate_credentials(view.get_serializer_context())
 
 
     def authenticate_credentials(self, context):
     def authenticate_credentials(self, context):
         serializer = AuthenticatedBasicUserActionSerializer(data={}, context=context)
         serializer = AuthenticatedBasicUserActionSerializer(data={}, context=context)
         serializer.is_valid(raise_exception=True)
         serializer.is_valid(raise_exception=True)
-        user = serializer.validated_data['user']
+        user = serializer.validated_data["user"]
 
 
-        email_verified = datetime.datetime.fromtimestamp(serializer.timestamp, datetime.timezone.utc)
+        email_verified = datetime.datetime.fromtimestamp(
+            serializer.timestamp, datetime.timezone.utc
+        )
         user.email_verified = max(user.email_verified or email_verified, email_verified)
         user.email_verified = max(user.email_verified or email_verified, email_verified)
         user.save()
         user.save()
 
 
         # When user.is_active is None, activation is pending.  We need to admit them to finish activation, so only
         # When user.is_active is None, activation is pending.  We need to admit them to finish activation, so only
         # reject strictly False.  There are permissions to make sure that such accounts can't do anything else.
         # reject strictly False.  There are permissions to make sure that such accounts can't do anything else.
         if user.is_active == False:
         if user.is_active == False:
-            raise exceptions.AuthenticationFailed('User inactive.')
+            raise exceptions.AuthenticationFailed("User inactive.")
         return user, None
         return user, None
 
 
 
 
 class TokenHasher(PBKDF2PasswordHasher):
 class TokenHasher(PBKDF2PasswordHasher):
-    algorithm = 'pbkdf2_sha256_iter1'
+    algorithm = "pbkdf2_sha256_iter1"
     iterations = 1
     iterations = 1

+ 16 - 6
api/desecapi/crypto.py

@@ -12,8 +12,18 @@ from desecapi import metrics
 
 
 def _derive_urlsafe_key(*, label, context):
 def _derive_urlsafe_key(*, label, context):
     backend = default_backend()
     backend = default_backend()
-    kdf = KBKDFHMAC(algorithm=hashes.SHA256(), mode=Mode.CounterMode, length=32, rlen=4, llen=4,
-                    location=CounterLocation.BeforeFixed, label=label, context=context, fixed=None, backend=backend)
+    kdf = KBKDFHMAC(
+        algorithm=hashes.SHA256(),
+        mode=Mode.CounterMode,
+        length=32,
+        rlen=4,
+        llen=4,
+        location=CounterLocation.BeforeFixed,
+        label=label,
+        context=context,
+        fixed=None,
+        backend=backend,
+    )
     key = kdf.derive(settings.SECRET_KEY.encode())
     key = kdf.derive(settings.SECRET_KEY.encode())
     return urlsafe_b64encode(key)
     return urlsafe_b64encode(key)
 
 
@@ -26,18 +36,18 @@ def retrieve_key(*, label, context):
 
 
 
 
 def encrypt(data, *, context):
 def encrypt(data, *, context):
-    key = retrieve_key(label=b'crypt', context=context)
+    key = retrieve_key(label=b"crypt", context=context)
     value = Fernet(key=key).encrypt(data)
     value = Fernet(key=key).encrypt(data)
-    metrics.get('desecapi_key_encryption_success').labels(context).inc()
+    metrics.get("desecapi_key_encryption_success").labels(context).inc()
     return value
     return value
 
 
 
 
 def decrypt(token, *, context, ttl=None):
 def decrypt(token, *, context, ttl=None):
-    key = retrieve_key(label=b'crypt', context=context)
+    key = retrieve_key(label=b"crypt", context=context)
     f = Fernet(key=key)
     f = Fernet(key=key)
     try:
     try:
         ret = f.extract_timestamp(token), f.decrypt(token, ttl=ttl)
         ret = f.extract_timestamp(token), f.decrypt(token, ttl=ttl)
-        metrics.get('desecapi_key_decryption_success').labels(context).inc()
+        metrics.get("desecapi_key_decryption_success").labels(context).inc()
         return ret
         return ret
     except InvalidToken:
     except InvalidToken:
         raise ValueError
         raise ValueError

+ 38 - 19
api/desecapi/dns.py

@@ -18,20 +18,35 @@ def _strip_quotes_decorator(func):
 # WARNING: This is a global side-effect. It can't be done by extending a class, because dnspython hardcodes the use of
 # WARNING: This is a global side-effect. It can't be done by extending a class, because dnspython hardcodes the use of
 # their dns.rdtypes.svcbbase.*Param classes in the global dns.rdtypes.svcbbase._class_for_key dictionary. We either have
 # their dns.rdtypes.svcbbase.*Param classes in the global dns.rdtypes.svcbbase._class_for_key dictionary. We either have
 # to globally mess with that dict and insert our custom class, or we just mess with their classes directly.
 # to globally mess with that dict and insert our custom class, or we just mess with their classes directly.
-dns.rdtypes.svcbbase.ALPNParam.to_text = _strip_quotes_decorator(dns.rdtypes.svcbbase.ALPNParam.to_text)
-dns.rdtypes.svcbbase.IPv4HintParam.to_text = _strip_quotes_decorator(dns.rdtypes.svcbbase.IPv4HintParam.to_text)
-dns.rdtypes.svcbbase.IPv6HintParam.to_text = _strip_quotes_decorator(dns.rdtypes.svcbbase.IPv6HintParam.to_text)
-dns.rdtypes.svcbbase.MandatoryParam.to_text = _strip_quotes_decorator(dns.rdtypes.svcbbase.MandatoryParam.to_text)
-dns.rdtypes.svcbbase.PortParam.to_text = _strip_quotes_decorator(dns.rdtypes.svcbbase.PortParam.to_text)
+dns.rdtypes.svcbbase.ALPNParam.to_text = _strip_quotes_decorator(
+    dns.rdtypes.svcbbase.ALPNParam.to_text
+)
+dns.rdtypes.svcbbase.IPv4HintParam.to_text = _strip_quotes_decorator(
+    dns.rdtypes.svcbbase.IPv4HintParam.to_text
+)
+dns.rdtypes.svcbbase.IPv6HintParam.to_text = _strip_quotes_decorator(
+    dns.rdtypes.svcbbase.IPv6HintParam.to_text
+)
+dns.rdtypes.svcbbase.MandatoryParam.to_text = _strip_quotes_decorator(
+    dns.rdtypes.svcbbase.MandatoryParam.to_text
+)
+dns.rdtypes.svcbbase.PortParam.to_text = _strip_quotes_decorator(
+    dns.rdtypes.svcbbase.PortParam.to_text
+)
 
 
 
 
 @dns.immutable.immutable
 @dns.immutable.immutable
 class CERT(dns.rdtypes.ANY.CERT.CERT):
 class CERT(dns.rdtypes.ANY.CERT.CERT):
     def to_text(self, origin=None, relativize=True, **kw):
     def to_text(self, origin=None, relativize=True, **kw):
-        certificate_type = str(self.certificate_type)  # upstream implementation calls _ctype_to_text
-        return "%s %d %s %s" % (certificate_type, self.key_tag,
-                                dns.dnssec.algorithm_to_text(self.algorithm),
-                                dns.rdata._base64ify(self.certificate, **kw))
+        certificate_type = str(
+            self.certificate_type
+        )  # upstream implementation calls _ctype_to_text
+        return "%s %d %s %s" % (
+            certificate_type,
+            self.key_tag,
+            dns.dnssec.algorithm_to_text(self.algorithm),
+            dns.rdata._base64ify(self.certificate, **kw),
+        )
 
 
 
 
 @dns.immutable.immutable
 @dns.immutable.immutable
@@ -52,8 +67,9 @@ class LongQuotedTXT(dns.rdtypes.txtbase.TXTBase):
     def __init__(self, rdclass, rdtype, strings):
     def __init__(self, rdclass, rdtype, strings):
         # Same as in parent class, but with max_length=None. Note that we are calling __init__ from the grandparent.
         # Same as in parent class, but with max_length=None. Note that we are calling __init__ from the grandparent.
         super(dns.rdtypes.txtbase.TXTBase, self).__init__(rdclass, rdtype)
         super(dns.rdtypes.txtbase.TXTBase, self).__init__(rdclass, rdtype)
-        self.strings = self._as_tuple(strings,
-                                      lambda x: self._as_bytes(x, True, max_length=None))
+        self.strings = self._as_tuple(
+            strings, lambda x: self._as_bytes(x, True, max_length=None)
+        )
 
 
     @classmethod
     @classmethod
     def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True):
     def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True):
@@ -71,37 +87,40 @@ class LongQuotedTXT(dns.rdtypes.txtbase.TXTBase):
 
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
         for long_s in self.strings:
         for long_s in self.strings:
-            for s in [long_s[i:i+255] for i in range(0, max(len(long_s), 1), 255)]:
+            for s in [long_s[i : i + 255] for i in range(0, max(len(long_s), 1), 255)]:
                 l = len(s)
                 l = len(s)
                 assert l < 256
                 assert l < 256
-                file.write(struct.pack('!B', l))
+                file.write(struct.pack("!B", l))
                 file.write(s)
                 file.write(s)
 
 
 
 
 def _HostnameMixin(name_field, *, allow_root):
 def _HostnameMixin(name_field, *, allow_root):
     # Taken from https://github.com/PowerDNS/pdns/blob/4646277d05f293777a3d2423a3b188ccdf42c6bc/pdns/dnsname.cc#L419
     # Taken from https://github.com/PowerDNS/pdns/blob/4646277d05f293777a3d2423a3b188ccdf42c6bc/pdns/dnsname.cc#L419
-    hostname_re = re.compile(r'^(([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)\.)+$')
+    hostname_re = re.compile(r"^(([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)\.)+$")
 
 
     class Mixin:
     class Mixin:
         def to_text(self, origin=None, relativize=True, **kw):
         def to_text(self, origin=None, relativize=True, **kw):
             name = getattr(self, name_field)
             name = getattr(self, name_field)
-            if not (allow_root and name == dns.name.root) and hostname_re.match(str(name)) is None:
-                raise ValueError(f'invalid {name_field}: {name}')
+            if (
+                not (allow_root and name == dns.name.root)
+                and hostname_re.match(str(name)) is None
+            ):
+                raise ValueError(f"invalid {name_field}: {name}")
             return super().to_text(origin, relativize, **kw)
             return super().to_text(origin, relativize, **kw)
 
 
     return Mixin
     return Mixin
 
 
 
 
 @dns.immutable.immutable
 @dns.immutable.immutable
-class MX(_HostnameMixin('exchange', allow_root=True), dns.rdtypes.ANY.MX.MX):
+class MX(_HostnameMixin("exchange", allow_root=True), dns.rdtypes.ANY.MX.MX):
     pass
     pass
 
 
 
 
 @dns.immutable.immutable
 @dns.immutable.immutable
-class NS(_HostnameMixin('target', allow_root=False), dns.rdtypes.ANY.NS.NS):
+class NS(_HostnameMixin("target", allow_root=False), dns.rdtypes.ANY.NS.NS):
     pass
     pass
 
 
 
 
 @dns.immutable.immutable
 @dns.immutable.immutable
-class SRV(_HostnameMixin('target', allow_root=True), dns.rdtypes.IN.SRV.SRV):
+class SRV(_HostnameMixin("target", allow_root=True), dns.rdtypes.IN.SRV.SRV):
     pass
     pass

+ 21 - 11
api/desecapi/exception_handlers.py

@@ -17,25 +17,35 @@ def exception_handler(exc, context):
     """
     """
 
 
     def _log():
     def _log():
-        logger = logging.getLogger('django.request')
-        logger.error('{} Supplementary Information'.format(exc.__class__),
-                     exc_info=exc, stack_info=False)
+        logger = logging.getLogger("django.request")
+        logger.error(
+            "{} Supplementary Information".format(exc.__class__),
+            exc_info=exc,
+            stack_info=False,
+        )
 
 
     def _409():
     def _409():
-        return Response({'detail': f'Conflict: {exc}'}, status=status.HTTP_409_CONFLICT)
+        return Response({"detail": f"Conflict: {exc}"}, status=status.HTTP_409_CONFLICT)
 
 
     def _500():
     def _500():
-        return Response({'detail': "Internal Server Error. We're on it!"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+        return Response(
+            {"detail": "Internal Server Error. We're on it!"},
+            status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+        )
 
 
     def _503():
     def _503():
-        return Response({'detail': 'Please try again later.'}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
+        return Response(
+            {"detail": "Please try again later."},
+            status=status.HTTP_503_SERVICE_UNAVAILABLE,
+        )
 
 
     # Catch DB OperationalError and log an extra error for additional context
     # Catch DB OperationalError and log an extra error for additional context
     if (
     if (
-        isinstance(exc, OperationalError) and
-        isinstance(exc.args, (list, dict, tuple)) and
-        exc.args and
-        exc.args[0] in (
+        isinstance(exc, OperationalError)
+        and isinstance(exc.args, (list, dict, tuple))
+        and exc.args
+        and exc.args[0]
+        in (
             2002,  # Connection refused (Socket)
             2002,  # Connection refused (Socket)
             2003,  # Connection refused (TCP)
             2003,  # Connection refused (TCP)
             2005,  # Unresolved host name
             2005,  # Unresolved host name
@@ -45,7 +55,7 @@ def exception_handler(exc, context):
         )
         )
     ):
     ):
         _log()
         _log()
-        metrics.get('desecapi_database_unavailable').inc()
+        metrics.get("desecapi_database_unavailable").inc()
         return _503()
         return _503()
 
 
     handlers = {
     handlers = {

+ 9 - 5
api/desecapi/exceptions.py

@@ -4,18 +4,22 @@ from rest_framework.exceptions import APIException
 
 
 class RequestEntityTooLarge(APIException):
 class RequestEntityTooLarge(APIException):
     status_code = status.HTTP_413_REQUEST_ENTITY_TOO_LARGE
     status_code = status.HTTP_413_REQUEST_ENTITY_TOO_LARGE
-    default_detail = 'Payload too large.'
-    default_code = 'too_large'
+    default_detail = "Payload too large."
+    default_code = "too_large"
 
 
 
 
 class PDNSException(APIException):
 class PDNSException(APIException):
     def __init__(self, response=None):
     def __init__(self, response=None):
         self.response = response
         self.response = response
-        detail = f'pdns response code: {response.status_code}, body: {response.text}' if response is not None else None
+        detail = (
+            f"pdns response code: {response.status_code}, body: {response.text}"
+            if response is not None
+            else None
+        )
         return super().__init__(detail)
         return super().__init__(detail)
 
 
 
 
 class ConcurrencyException(APIException):
 class ConcurrencyException(APIException):
     status_code = status.HTTP_429_TOO_MANY_REQUESTS
     status_code = status.HTTP_429_TOO_MANY_REQUESTS
-    default_detail = 'Too many concurrent requests.'
-    default_code = 'concurrency_conflict'
+    default_detail = "Too many concurrent requests."
+    default_code = "concurrency_conflict"

+ 14 - 10
api/desecapi/mail_backends.py

@@ -13,8 +13,8 @@ logger = logging.getLogger(__name__)
 
 
 
 
 class MultiLaneEmailBackend(BaseEmailBackend):
 class MultiLaneEmailBackend(BaseEmailBackend):
-    config = {'ignore_result': True}
-    default_backend = 'django.core.mail.backends.smtp.EmailBackend'
+    config = {"ignore_result": True}
+    default_backend = "django.core.mail.backends.smtp.EmailBackend"
 
 
     def __init__(self, lane: str = None, fail_silently=False, **kwargs):
     def __init__(self, lane: str = None, fail_silently=False, **kwargs):
         lane = lane or next(iter(settings.TASK_CONFIG))
         lane = lane or next(iter(settings.TASK_CONFIG))
@@ -22,22 +22,26 @@ class MultiLaneEmailBackend(BaseEmailBackend):
         self.config.update(settings.TASK_CONFIG[lane])
         self.config.update(settings.TASK_CONFIG[lane])
         self.task_kwargs = kwargs.copy()
         self.task_kwargs = kwargs.copy()
         # Make a copy to ensure we don't modify input dict when we set the 'lane'
         # Make a copy to ensure we don't modify input dict when we set the 'lane'
-        self.task_kwargs['debug'] = self.task_kwargs.pop('debug', {}).copy()
-        self.task_kwargs['debug']['lane'] = lane
+        self.task_kwargs["debug"] = self.task_kwargs.pop("debug", {}).copy()
+        self.task_kwargs["debug"]["lane"] = lane
         super().__init__(fail_silently)
         super().__init__(fail_silently)
 
 
     def send_messages(self, email_messages):
     def send_messages(self, email_messages):
         dict_messages = [email_to_dict(msg) for msg in email_messages]
         dict_messages = [email_to_dict(msg) for msg in email_messages]
-        TASKS[self.config['name']].delay(dict_messages, **self.task_kwargs)
+        TASKS[self.config["name"]].delay(dict_messages, **self.task_kwargs)
         return len(email_messages)
         return len(email_messages)
 
 
     @staticmethod
     @staticmethod
     def _run_task(messages, debug, **kwargs):
     def _run_task(messages, debug, **kwargs):
-        logger.warning('Sending queued email, details: %s', debug)
-        kwargs.setdefault('backend', kwargs.pop('backbackend', MultiLaneEmailBackend.default_backend))
+        logger.warning("Sending queued email, details: %s", debug)
+        kwargs.setdefault(
+            "backend", kwargs.pop("backbackend", MultiLaneEmailBackend.default_backend)
+        )
         with get_connection(**kwargs) as connection:
         with get_connection(**kwargs) as connection:
-            return connection.send_messages([dict_to_email(message) for message in messages])
-        
+            return connection.send_messages(
+                [dict_to_email(message) for message in messages]
+            )
+
     @property
     @property
     def task(self):
     def task(self):
         return shared_task(**self.config)(self._run_task)
         return shared_task(**self.config)(self._run_task)
@@ -47,5 +51,5 @@ class MultiLaneEmailBackend(BaseEmailBackend):
 TASKS = {
 TASKS = {
     name: MultiLaneEmailBackend(lane=name, fail_silently=True).task
     name: MultiLaneEmailBackend(lane=name, fail_silently=True).task
     for name in settings.TASK_CONFIG
     for name in settings.TASK_CONFIG
-    if name.startswith('email_')
+    if name.startswith("email_")
 }
 }

+ 32 - 18
api/desecapi/management/commands/align-catalog-zone.py

@@ -2,12 +2,20 @@ from django.conf import settings
 from django.core.management import BaseCommand
 from django.core.management import BaseCommand
 
 
 from desecapi.exceptions import PDNSException
 from desecapi.exceptions import PDNSException
-from desecapi.pdns import _pdns_delete, _pdns_get, _pdns_post, NSLORD, NSMASTER, pdns_id, construct_catalog_rrset
+from desecapi.pdns import (
+    _pdns_delete,
+    _pdns_get,
+    _pdns_post,
+    NSLORD,
+    NSMASTER,
+    pdns_id,
+    construct_catalog_rrset,
+)
 
 
 
 
 class Command(BaseCommand):
 class Command(BaseCommand):
     # https://tools.ietf.org/html/draft-muks-dnsop-dns-catalog-zones-04
     # https://tools.ietf.org/html/draft-muks-dnsop-dns-catalog-zones-04
-    help = 'Generate a catalog zone on nsmaster, based on zones known on nslord.'
+    help = "Generate a catalog zone on nsmaster, based on zones known on nslord."
 
 
     def add_arguments(self, parser):
     def add_arguments(self, parser):
         pass
         pass
@@ -16,13 +24,13 @@ class Command(BaseCommand):
         catalog_zone_id = pdns_id(settings.CATALOG_ZONE)
         catalog_zone_id = pdns_id(settings.CATALOG_ZONE)
 
 
         # Fetch zones from NSLORD
         # Fetch zones from NSLORD
-        response = _pdns_get(NSLORD, '/zones').json()
-        zones = {zone['name'] for zone in response}
+        response = _pdns_get(NSLORD, "/zones").json()
+        zones = {zone["name"] for zone in response}
 
 
         # Retrieve catalog zone serial (later reused for recreating the catalog zone, for allow for smooth rollover)
         # Retrieve catalog zone serial (later reused for recreating the catalog zone, for allow for smooth rollover)
         try:
         try:
-            response = _pdns_get(NSMASTER, f'/zones/{catalog_zone_id}')
-            serial = response.json()['serial']
+            response = _pdns_get(NSMASTER, f"/zones/{catalog_zone_id}")
+            serial = response.json()["serial"]
         except PDNSException as e:
         except PDNSException as e:
             if e.response.status_code == 404:
             if e.response.status_code == 404:
                 serial = None
                 serial = None
@@ -31,28 +39,34 @@ class Command(BaseCommand):
 
 
         # Purge catalog zone if exists
         # Purge catalog zone if exists
         try:
         try:
-            _pdns_delete(NSMASTER, f'/zones/{catalog_zone_id}')
+            _pdns_delete(NSMASTER, f"/zones/{catalog_zone_id}")
         except PDNSException as e:
         except PDNSException as e:
             if e.response.status_code != 404:
             if e.response.status_code != 404:
                 raise e
                 raise e
 
 
         # Create new catalog zone
         # Create new catalog zone
         rrsets = [
         rrsets = [
-            construct_catalog_rrset(subname='', qtype='NS', rdata='invalid.'),  # as per the specification
-            construct_catalog_rrset(subname='version', qtype='TXT', rdata='"2"'),  # as per the specification
-            *(construct_catalog_rrset(zone=zone) for zone in zones)
+            construct_catalog_rrset(
+                subname="", qtype="NS", rdata="invalid."
+            ),  # as per the specification
+            construct_catalog_rrset(
+                subname="version", qtype="TXT", rdata='"2"'
+            ),  # as per the specification
+            *(construct_catalog_rrset(zone=zone) for zone in zones),
         ]
         ]
 
 
         data = {
         data = {
-            'name': settings.CATALOG_ZONE + '.',
-            'kind': 'MASTER',
-            'dnssec': False,  # as per the specification
-            'nameservers': [],
-            'rrsets': rrsets,
+            "name": settings.CATALOG_ZONE + ".",
+            "kind": "MASTER",
+            "dnssec": False,  # as per the specification
+            "nameservers": [],
+            "rrsets": rrsets,
         }
         }
 
 
         if serial is not None:
         if serial is not None:
-            data['serial'] = serial + 1  # actually, pdns does increase this as well, but let's not rely on this
+            data["serial"] = (
+                serial + 1
+            )  # actually, pdns does increase this as well, but let's not rely on this
 
 
-        _pdns_post(NSMASTER, '/zones?rrsets=false', data)
-        print(f'Aligned catalog zone ({len(zones)} member zones).')
+        _pdns_post(NSMASTER, "/zones?rrsets=false", data)
+        print(f"Aligned catalog zone ({len(zones)} member zones).")

+ 69 - 29
api/desecapi/management/commands/check-secondaries.py

@@ -17,7 +17,7 @@ def query_serial(zone, server):
     Checks a zone's serial on a server.
     Checks a zone's serial on a server.
     :return: serial if received; None if the server did not know; False on error
     :return: serial if received; None if the server did not know; False on error
     """
     """
-    query = dns.message.make_query(zone, 'SOA')
+    query = dns.message.make_query(zone, "SOA")
     try:
     try:
         response = dns.query.tcp(query, server, timeout=5)
         response = dns.query.tcp(query, server, timeout=5)
     except dns.exception.Timeout:
     except dns.exception.Timeout:
@@ -30,18 +30,32 @@ def query_serial(zone, server):
 
 
 
 
 class Command(BaseCommand):
 class Command(BaseCommand):
-    help = 'Check secondaries for consistency with nsmaster.'
+    help = "Check secondaries for consistency with nsmaster."
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
-        self.servers = {gethostbyname(server): server for server in settings.WATCHDOG_SECONDARIES}
+        self.servers = {
+            gethostbyname(server): server for server in settings.WATCHDOG_SECONDARIES
+        }
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
     def add_arguments(self, parser):
     def add_arguments(self, parser):
-        parser.add_argument('domain-name', nargs='*',
-                            help='Domain name to check. If omitted, will check all recently published domains.')
-        parser.add_argument('--delay', type=int, default=120, help='Delay SOA checks to allow pending AXFRs to finish.')
-        parser.add_argument('--window', type=int, default=settings.WATCHDOG_WINDOW_SEC,
-                            help='Check domains that were published no longer than this many seconds ago.')
+        parser.add_argument(
+            "domain-name",
+            nargs="*",
+            help="Domain name to check. If omitted, will check all recently published domains.",
+        )
+        parser.add_argument(
+            "--delay",
+            type=int,
+            default=120,
+            help="Delay SOA checks to allow pending AXFRs to finish.",
+        )
+        parser.add_argument(
+            "--window",
+            type=int,
+            default=settings.WATCHDOG_WINDOW_SEC,
+            help="Check domains that were published no longer than this many seconds ago.",
+        )
 
 
     def find_outdated_servers(self, zone, local_serial):
     def find_outdated_servers(self, zone, local_serial):
         """
         """
@@ -56,15 +70,29 @@ class Command(BaseCommand):
         return outdated
         return outdated
 
 
     def handle(self, *args, **options):
     def handle(self, *args, **options):
-        threshold = timezone.now() - timedelta(seconds=options['window'])
-        recent_domain_names = Domain.objects.filter(published__gt=threshold).values_list('name', flat=True)
-        serials = {zone: s for zone, s in pdns.get_serials().items() if zone.rstrip('.') in recent_domain_names}
-
-        if options['domain-name']:
-            serials = {zone: serial for zone, serial in serials.items() if zone.rstrip('.') in options['domain-name']}
-
-        print('Sleeping for {} seconds before checking {} domains ...'.format(options['delay'], len(serials)))
-        sleep(options['delay'])
+        threshold = timezone.now() - timedelta(seconds=options["window"])
+        recent_domain_names = Domain.objects.filter(
+            published__gt=threshold
+        ).values_list("name", flat=True)
+        serials = {
+            zone: s
+            for zone, s in pdns.get_serials().items()
+            if zone.rstrip(".") in recent_domain_names
+        }
+
+        if options["domain-name"]:
+            serials = {
+                zone: serial
+                for zone, serial in serials.items()
+                if zone.rstrip(".") in options["domain-name"]
+            }
+
+        print(
+            "Sleeping for {} seconds before checking {} domains ...".format(
+                options["delay"], len(serials)
+            )
+        )
+        sleep(options["delay"])
 
 
         outdated_zone_count = 0
         outdated_zone_count = 0
         outdated_secondaries = set()
         outdated_secondaries = set()
@@ -77,17 +105,25 @@ class Command(BaseCommand):
                 if serial is False:
                 if serial is False:
                     timeouts.setdefault(server, [])
                     timeouts.setdefault(server, [])
                     timeouts[server].append(zone)
                     timeouts[server].append(zone)
-            outdated_serials = {k: serial for k, serial in outdated_serials.items() if serial is not False}
+            outdated_serials = {
+                k: serial
+                for k, serial in outdated_serials.items()
+                if serial is not False
+            }
 
 
             if outdated_serials:
             if outdated_serials:
                 outdated_secondaries.update(outdated_serials.keys())
                 outdated_secondaries.update(outdated_serials.keys())
-                output.append(f'{zone} ({local_serial}) is outdated on {outdated_serials}')
+                output.append(
+                    f"{zone} ({local_serial}) is outdated on {outdated_serials}"
+                )
                 print(output[-1])
                 print(output[-1])
                 outdated_zone_count += 1
                 outdated_zone_count += 1
             else:
             else:
-                print(f'{zone} ok')
+                print(f"{zone} ok")
 
 
-        output.append(f'Checked {len(serials)} domains, {outdated_zone_count} were outdated.')
+        output.append(
+            f"Checked {len(serials)} domains, {outdated_zone_count} were outdated."
+        )
         print(output[-1])
         print(output[-1])
 
 
         self.report(outdated_secondaries, output, timeouts)
         self.report(outdated_secondaries, output, timeouts)
@@ -97,18 +133,22 @@ class Command(BaseCommand):
             return
             return
 
 
         subject = f'{timeouts and "CRITICAL ALERT" or "ALERT"} {len(outdated_secondaries)} secondaries out of sync'
         subject = f'{timeouts and "CRITICAL ALERT" or "ALERT"} {len(outdated_secondaries)} secondaries out of sync'
-        message = ''
+        message = ""
 
 
         if timeouts:
         if timeouts:
-            message += f'The following servers had timeouts:\n\n{timeouts}\n\n'
+            message += f"The following servers had timeouts:\n\n{timeouts}\n\n"
 
 
         if outdated_secondaries:
         if outdated_secondaries:
-            message += f'The following {len(outdated_secondaries)} secondaries are out of sync:\n'
+            message += f"The following {len(outdated_secondaries)} secondaries are out of sync:\n"
             for outdated_secondary in outdated_secondaries:
             for outdated_secondary in outdated_secondaries:
-                message += f'* {outdated_secondary}\n'
-            message += '\n'
+                message += f"* {outdated_secondary}\n"
+            message += "\n"
 
 
-        message += f'Current secondary IPs: {self.servers}\n'
-        message += '\n'.join(output)
+        message += f"Current secondary IPs: {self.servers}\n"
+        message += "\n".join(output)
 
 
-        mail_admins(subject, message, connection=get_connection('django.core.mail.backends.smtp.EmailBackend'))
+        mail_admins(
+            subject,
+            message,
+            connection=get_connection("django.core.mail.backends.smtp.EmailBackend"),
+        )

+ 46 - 32
api/desecapi/management/commands/chores.py

@@ -12,77 +12,85 @@ from desecapi.pdns_change_tracker import PDNSChangeTracker
 
 
 
 
 class Command(BaseCommand):
 class Command(BaseCommand):
-
     @staticmethod
     @staticmethod
     def delete_expired_captchas():
     def delete_expired_captchas():
-        models.Captcha.objects.filter(created__lt=timezone.now() - settings.CAPTCHA_VALIDITY_PERIOD).delete()
+        models.Captcha.objects.filter(
+            created__lt=timezone.now() - settings.CAPTCHA_VALIDITY_PERIOD
+        ).delete()
 
 
     @staticmethod
     @staticmethod
     def delete_never_activated_users():
     def delete_never_activated_users():
         # delete inactive users whose activation link expired and who never logged in
         # delete inactive users whose activation link expired and who never logged in
         # (this will not delete users who have used their account and were later disabled)
         # (this will not delete users who have used their account and were later disabled)
-        models.User.objects.filter(is_active__isnull=True, last_login__isnull=True,
-                            created__lt=timezone.now() - settings.VALIDITY_PERIOD_VERIFICATION_SIGNATURE).delete()
+        models.User.objects.filter(
+            is_active__isnull=True,
+            last_login__isnull=True,
+            created__lt=timezone.now()
+            - settings.VALIDITY_PERIOD_VERIFICATION_SIGNATURE,
+        ).delete()
 
 
     @staticmethod
     @staticmethod
     def update_healthcheck_timestamp():
     def update_healthcheck_timestamp():
-        name = 'internal-timestamp.desec.test'
+        name = "internal-timestamp.desec.test"
         try:
         try:
             domain = models.Domain.objects.get(name=name)
             domain = models.Domain.objects.get(name=name)
         except models.Domain.DoesNotExist:
         except models.Domain.DoesNotExist:
             # Fail silently. If external alerting is configured, it will catch the problem; otherwise, we don't need it.
             # Fail silently. If external alerting is configured, it will catch the problem; otherwise, we don't need it.
-            print(f'{name} zone is not configured; skipping TXT record update')
+            print(f"{name} zone is not configured; skipping TXT record update")
             return
             return
 
 
-        instances = domain.rrset_set.filter(subname='', type='TXT').all()
+        instances = domain.rrset_set.filter(subname="", type="TXT").all()
         timestamp = int(time.time())
         timestamp = int(time.time())
         content = f'"{timestamp}"'
         content = f'"{timestamp}"'
-        data = [{
-            'subname': '',
-            'type': 'TXT',
-            'ttl': '3600',
-            'records': [content]
-        }]
-        context = {'domain': domain}
-        serializer = serializers.RRsetSerializer(instances, data=data, many=True, partial=True, context=context)
+        data = [{"subname": "", "type": "TXT", "ttl": "3600", "records": [content]}]
+        context = {"domain": domain}
+        serializer = serializers.RRsetSerializer(
+            instances, data=data, many=True, partial=True, context=context
+        )
         serializer.is_valid(raise_exception=True)
         serializer.is_valid(raise_exception=True)
         with PDNSChangeTracker():
         with PDNSChangeTracker():
             serializer.save()
             serializer.save()
-        print(f'TXT {name} updated to {content}')
+        print(f"TXT {name} updated to {content}")
 
 
     @staticmethod
     @staticmethod
     def alerting_healthcheck():
     def alerting_healthcheck():
-        name = 'external-timestamp.desec.test'
+        name = "external-timestamp.desec.test"
         try:
         try:
             models.Domain.objects.get(name=name)
             models.Domain.objects.get(name=name)
         except models.Domain.DoesNotExist:
         except models.Domain.DoesNotExist:
-            print(f'{name} zone is not configured; skipping alerting health check')
+            print(f"{name} zone is not configured; skipping alerting health check")
             return
             return
 
 
         timestamps = []
         timestamps = []
         qname = dns.name.from_text(name)
         qname = dns.name.from_text(name)
         query = dns.message.make_query(qname, dns.rdatatype.TXT)
         query = dns.message.make_query(qname, dns.rdatatype.TXT)
-        server = gethostbyname('ns1.desec.io')
+        server = gethostbyname("ns1.desec.io")
         response = None
         response = None
         try:
         try:
             response = dns.query.tcp(query, server, timeout=5)
             response = dns.query.tcp(query, server, timeout=5)
-            for content in response.find_rrset(dns.message.ANSWER, qname, dns.rdataclass.IN, dns.rdatatype.TXT):
+            for content in response.find_rrset(
+                dns.message.ANSWER, qname, dns.rdataclass.IN, dns.rdatatype.TXT
+            ):
                 timestamps.append(str(content)[1:-1])
                 timestamps.append(str(content)[1:-1])
         except Exception:
         except Exception:
             pass
             pass
 
 
         now = time.time()
         now = time.time()
         if any(now - 600 <= int(timestamp) <= now for timestamp in timestamps):
         if any(now - 600 <= int(timestamp) <= now for timestamp in timestamps):
-            print(f'TXT {name} up to date.')
+            print(f"TXT {name} up to date.")
             return
             return
 
 
-        timestamps = ', '.join(timestamps)
-        print(f'TXT {name} out of date! Timestamps: {timestamps}')
-        subject = 'ALERT Alerting system down?'
-        message = f'TXT query for {name} on {server} gave the following response:\n'
-        message += f'{str(response)}\n\n'
-        message += f'Extracted timestamps in TXT RRset:\n{timestamps}'
-        mail_admins(subject, message, connection=get_connection('django.core.mail.backends.smtp.EmailBackend'))
+        timestamps = ", ".join(timestamps)
+        print(f"TXT {name} out of date! Timestamps: {timestamps}")
+        subject = "ALERT Alerting system down?"
+        message = f"TXT query for {name} on {server} gave the following response:\n"
+        message += f"{str(response)}\n\n"
+        message += f"Extracted timestamps in TXT RRset:\n{timestamps}"
+        mail_admins(
+            subject,
+            message,
+            connection=get_connection("django.core.mail.backends.smtp.EmailBackend"),
+        )
 
 
     def handle(self, *args, **kwargs):
     def handle(self, *args, **kwargs):
         try:
         try:
@@ -91,7 +99,13 @@ class Command(BaseCommand):
             self.delete_expired_captchas()
             self.delete_expired_captchas()
             self.delete_never_activated_users()
             self.delete_never_activated_users()
         except Exception as e:
         except Exception as e:
-            subject = 'chores Exception!'
-            message = f'{type(e)}\n\n{str(e)}'
-            print(f'Chores exception: {type(e)}, {str(e)}')
-            mail_admins(subject, message, connection=get_connection('django.core.mail.backends.smtp.EmailBackend'))
+            subject = "chores Exception!"
+            message = f"{type(e)}\n\n{str(e)}"
+            print(f"Chores exception: {type(e)}, {str(e)}")
+            mail_admins(
+                subject,
+                message,
+                connection=get_connection(
+                    "django.core.mail.backends.smtp.EmailBackend"
+                ),
+            )

+ 27 - 17
api/desecapi/management/commands/limit.py

@@ -7,32 +7,42 @@ from desecapi.pdns_change_tracker import PDNSChangeTracker
 
 
 
 
 class Command(BaseCommand):
 class Command(BaseCommand):
-    help = 'Sets/updates limits for users and domains.'
+    help = "Sets/updates limits for users and domains."
 
 
     def add_arguments(self, parser):
     def add_arguments(self, parser):
-        parser.add_argument('kind',
-                            help='Identifies which limit should be updated. Possible values: domains, ttl')
-        parser.add_argument('id',
-                            help='Identifies the entity to be updated. Users are identified by email address; '
-                                 'domains by their name.')
-        parser.add_argument('new_limit', help='New value for the limit.')
+        parser.add_argument(
+            "kind",
+            help="Identifies which limit should be updated. Possible values: domains, ttl",
+        )
+        parser.add_argument(
+            "id",
+            help="Identifies the entity to be updated. Users are identified by email address; "
+            "domains by their name.",
+        )
+        parser.add_argument("new_limit", help="New value for the limit.")
 
 
     def handle(self, *args, **options):
     def handle(self, *args, **options):
-        if options['kind'] == 'domains':
+        if options["kind"] == "domains":
             try:
             try:
-                user = User.objects.get(email=options['id'])
+                user = User.objects.get(email=options["id"])
             except User.DoesNotExist:
             except User.DoesNotExist:
-                raise CommandError(f'User with email address "{options["id"]}" could not be found.')
-            user.limit_domains = options['new_limit']
+                raise CommandError(
+                    f'User with email address "{options["id"]}" could not be found.'
+                )
+            user.limit_domains = options["new_limit"]
             user.save()
             user.save()
-            print(f'Updated {user.email}: set max number of domains to {user.limit_domains}.')
-        elif options['kind'] == 'ttl':
+            print(
+                f"Updated {user.email}: set max number of domains to {user.limit_domains}."
+            )
+        elif options["kind"] == "ttl":
             try:
             try:
-                domain = Domain.objects.get(name=options['id'])
+                domain = Domain.objects.get(name=options["id"])
             except Domain.DoesNotExist:
             except Domain.DoesNotExist:
-                raise CommandError(f'Domain with name "{options["id"]}" could not be found.')
-            domain.minimum_ttl = options['new_limit']
+                raise CommandError(
+                    f'Domain with name "{options["id"]}" could not be found.'
+                )
+            domain.minimum_ttl = options["new_limit"]
             domain.save()
             domain.save()
-            print(f'Updated {domain.name}: set minimum TTL to {domain.minimum_ttl}.')
+            print(f"Updated {domain.name}: set minimum TTL to {domain.minimum_ttl}.")
         else:
         else:
             raise CommandError(f'Unknown limit "{options["kind"]}" specified.')
             raise CommandError(f'Unknown limit "{options["kind"]}" specified.')

+ 44 - 22
api/desecapi/management/commands/outreach-email.py

@@ -19,44 +19,66 @@ def _get_default_template_backend():
 
 
 
 
 class Command(BaseCommand):
 class Command(BaseCommand):
-    help = 'Reach out to users with an email. Takes email template on stdin.'
+    help = "Reach out to users with an email. Takes email template on stdin."
 
 
     def add_arguments(self, parser):
     def add_arguments(self, parser):
-        parser.add_argument('email', nargs='*', help='User(s) to contact, identified by their email addresses. '
-                            'Defaults to everyone with outreach_preference = True, excluding inactive users.')
-        parser.add_argument('--contentfile', nargs='?', type=argparse.FileType('r'), default=sys.stdin,
-                            help='File to take email content from. Defaults to stdin.')
-        parser.add_argument('--reason', nargs='?', default='change-outreach-preference',
-                            help='Kind of message to send. Choose from reasons given in serializers.py. Defaults to '
-                                 'newsletter with unsubscribe link (reason: change-outreach-preference).')
-        parser.add_argument('--subject', nargs='?', default=None, help='Subject, default according to "reason".')
+        parser.add_argument(
+            "email",
+            nargs="*",
+            help="User(s) to contact, identified by their email addresses. "
+            "Defaults to everyone with outreach_preference = True, excluding inactive users.",
+        )
+        parser.add_argument(
+            "--contentfile",
+            nargs="?",
+            type=argparse.FileType("r"),
+            default=sys.stdin,
+            help="File to take email content from. Defaults to stdin.",
+        )
+        parser.add_argument(
+            "--reason",
+            nargs="?",
+            default="change-outreach-preference",
+            help="Kind of message to send. Choose from reasons given in serializers.py. Defaults to "
+            "newsletter with unsubscribe link (reason: change-outreach-preference).",
+        )
+        parser.add_argument(
+            "--subject",
+            nargs="?",
+            default=None,
+            help='Subject, default according to "reason".',
+        )
 
 
     def handle(self, *args, **options):
     def handle(self, *args, **options):
-        reason = options['reason']
-        path = reverse(f'v1:confirm-{reason}', args=['code'])
+        reason = options["reason"]
+        path = reverse(f"v1:confirm-{reason}", args=["code"])
         serializer_class = resolve(path).func.cls.serializer_class
         serializer_class = resolve(path).func.cls.serializer_class
 
 
-        content = options['contentfile'].read().strip()
-        if not content and options['contentfile'].name != '/dev/null':
-            raise RuntimeError('Empty content only allowed from /dev/null')
+        content = options["contentfile"].read().strip()
+        if not content and options["contentfile"].name != "/dev/null":
+            raise RuntimeError("Empty content only allowed from /dev/null")
 
 
         try:
         try:
-            subject = '[deSEC] ' + options['subject']
+            subject = "[deSEC] " + options["subject"]
         except TypeError:
         except TypeError:
             subject = None
             subject = None
 
 
-        base_file = f'emails/{reason}/content.txt'
-        template_code = ('{%% extends "%s" %%}' % base_file)
+        base_file = f"emails/{reason}/content.txt"
+        template_code = '{%% extends "%s" %%}' % base_file
         if content:
         if content:
-            template_code += '{% block content %}' + content + '{% endblock %}'
+            template_code += "{% block content %}" + content + "{% endblock %}"
         template = _get_default_template_backend().from_string(template_code)
         template = _get_default_template_backend().from_string(template_code)
 
 
-        if options['email']:
-            users = User.objects.filter(email__in=options['email'])
+        if options["email"]:
+            users = User.objects.filter(email__in=options["email"])
         elif content:
         elif content:
-            users = User.objects.exclude(is_active=False).filter(outreach_preference=True)
+            users = User.objects.exclude(is_active=False).filter(
+                outreach_preference=True
+            )
         else:
         else:
-            raise RuntimeError('To send default content, specify recipients explicitly.')
+            raise RuntimeError(
+                "To send default content, specify recipients explicitly."
+            )
 
 
         for user in users:
         for user in users:
             action = serializer_class.Meta.model(user=user)
             action = serializer_class.Meta.model(user=user)

+ 66 - 28
api/desecapi/management/commands/scavenge-unused.py

@@ -17,40 +17,57 @@ notice_days_warn = 7
 
 
 
 
 class Command(BaseCommand):
 class Command(BaseCommand):
-    base_queryset = models.Domain.objects\
-        .exclude(renewal_state=models.Domain.RenewalState.IMMORTAL).filter(owner__is_active=True)
-    _rrsets_outer_queryset = models.RRset.objects.filter(domain=OuterRef('pk')).values('domain')  # values() is GROUP BY
-    _max_touched = Subquery(_rrsets_outer_queryset.annotate(max_touched=Max('touched')).values('max_touched'))
+    base_queryset = models.Domain.objects.exclude(
+        renewal_state=models.Domain.RenewalState.IMMORTAL
+    ).filter(owner__is_active=True)
+    _rrsets_outer_queryset = models.RRset.objects.filter(domain=OuterRef("pk")).values(
+        "domain"
+    )  # values() is GROUP BY
+    _max_touched = Subquery(
+        _rrsets_outer_queryset.annotate(max_touched=Max("touched")).values(
+            "max_touched"
+        )
+    )
 
 
     @classmethod
     @classmethod
     def renew_touched_domains(cls):
     def renew_touched_domains(cls):
         recently_active_domains = cls.base_queryset.annotate(
         recently_active_domains = cls.base_queryset.annotate(
-            last_active=Greatest(cls._max_touched, 'published')
+            last_active=Greatest(cls._max_touched, "published")
         ).filter(
         ).filter(
             last_active__date__gte=timezone.localdate() - datetime.timedelta(days=183),
             last_active__date__gte=timezone.localdate() - datetime.timedelta(days=183),
-            renewal_changed__lt=F('last_active'),
+            renewal_changed__lt=F("last_active"),
         )
         )
 
 
-        print('Renewing domains:', *recently_active_domains.values_list('name', flat=True))
-        recently_active_domains.update(renewal_state=models.Domain.RenewalState.FRESH, renewal_changed=F('last_active'))
+        print(
+            "Renewing domains:", *recently_active_domains.values_list("name", flat=True)
+        )
+        recently_active_domains.update(
+            renewal_state=models.Domain.RenewalState.FRESH,
+            renewal_changed=F("last_active"),
+        )
 
 
     @classmethod
     @classmethod
     def warn_domain_deletion(cls, renewal_state, notice_days, inactive_days):
     def warn_domain_deletion(cls, renewal_state, notice_days, inactive_days):
         # We act when `renewal_changed` is at least this date (or older)
         # We act when `renewal_changed` is at least this date (or older)
-        inactive_threshold = timezone.localdate() - datetime.timedelta(days=inactive_days)
+        inactive_threshold = timezone.localdate() - datetime.timedelta(
+            days=inactive_days
+        )
         # Filter candidates which have the state of interest, at least since the calculated date
         # Filter candidates which have the state of interest, at least since the calculated date
-        expiry_candidates = cls.base_queryset.filter(renewal_state=renewal_state,
-                                                     renewal_changed__date__lte=inactive_threshold)
+        expiry_candidates = cls.base_queryset.filter(
+            renewal_state=renewal_state, renewal_changed__date__lte=inactive_threshold
+        )
 
 
         # Group domains by user, so that we can send one message per user
         # Group domains by user, so that we can send one message per user
         domain_user_map = {}
         domain_user_map = {}
-        for domain in expiry_candidates.order_by('name'):
+        for domain in expiry_candidates.order_by("name"):
             if domain.owner not in domain_user_map:
             if domain.owner not in domain_user_map:
                 domain_user_map[domain.owner] = []
                 domain_user_map[domain.owner] = []
             domain_user_map[domain.owner].append(domain)
             domain_user_map[domain.owner].append(domain)
 
 
         # Prepare and send emails, and keep renewal status in sync
         # Prepare and send emails, and keep renewal status in sync
-        context = {'deletion_date': timezone.localdate() + datetime.timedelta(days=notice_days)}
+        context = {
+            "deletion_date": timezone.localdate() + datetime.timedelta(days=notice_days)
+        }
         for user, domains in domain_user_map.items():
         for user, domains in domain_user_map.items():
             with transaction.atomic():
             with transaction.atomic():
                 # Update renewal status of the user's affected domains, but don't commit before sending the email
                 # Update renewal status of the user's affected domains, but don't commit before sending the email
@@ -58,17 +75,27 @@ class Command(BaseCommand):
                 for domain in domains:
                 for domain in domains:
                     domain.renewal_state += 1
                     domain.renewal_state += 1
                     domain.renewal_changed = timezone.now()
                     domain.renewal_changed = timezone.now()
-                    domain.save(update_fields=['renewal_state', 'renewal_changed'])
-                    actions.append(models.AuthenticatedRenewDomainBasicUserAction(user=user, domain=domain))
-                serializers.AuthenticatedRenewDomainBasicUserActionSerializer(actions, many=True, context=context).save()
+                    domain.save(update_fields=["renewal_state", "renewal_changed"])
+                    actions.append(
+                        models.AuthenticatedRenewDomainBasicUserAction(
+                            user=user, domain=domain
+                        )
+                    )
+                serializers.AuthenticatedRenewDomainBasicUserActionSerializer(
+                    actions, many=True, context=context
+                ).save()
 
 
     @classmethod
     @classmethod
     def delete_domains(cls, inactive_days):
     def delete_domains(cls, inactive_days):
-        expired_domains = cls.base_queryset.filter(renewal_state=models.Domain.RenewalState.WARNED).annotate(
-            last_active=Greatest(cls._max_touched, 'published')
-        ).filter(
-            renewal_changed__date__lte=timezone.localdate() - datetime.timedelta(days=notice_days_warn),
-            last_active__date__lte=timezone.localdate() - datetime.timedelta(days=inactive_days),
+        expired_domains = (
+            cls.base_queryset.filter(renewal_state=models.Domain.RenewalState.WARNED)
+            .annotate(last_active=Greatest(cls._max_touched, "published"))
+            .filter(
+                renewal_changed__date__lte=timezone.localdate()
+                - datetime.timedelta(days=notice_days_warn),
+                last_active__date__lte=timezone.localdate()
+                - datetime.timedelta(days=inactive_days),
+            )
         )
         )
 
 
         for domain in expired_domains:
         for domain in expired_domains:
@@ -88,17 +115,28 @@ class Command(BaseCommand):
 
 
             # Announce domain deletion in `notice_days_notice` days if not yet notified (FRESH) and inactive for
             # Announce domain deletion in `notice_days_notice` days if not yet notified (FRESH) and inactive for
             # `inactive_days` days. Updates status from FRESH to NOTIFIED.
             # `inactive_days` days. Updates status from FRESH to NOTIFIED.
-            self.warn_domain_deletion(models.Domain.RenewalState.FRESH, notice_days_notify, fresh_days)
+            self.warn_domain_deletion(
+                models.Domain.RenewalState.FRESH, notice_days_notify, fresh_days
+            )
 
 
             # After `notice_days_notify - notice_days_warn` more days, warn again if the status has not changed
             # After `notice_days_notify - notice_days_warn` more days, warn again if the status has not changed
             # Updates status from NOTIFIED to WARNED.
             # Updates status from NOTIFIED to WARNED.
-            self.warn_domain_deletion(models.Domain.RenewalState.NOTIFIED, notice_days_warn,
-                                      notice_days_notify - notice_days_warn)
+            self.warn_domain_deletion(
+                models.Domain.RenewalState.NOTIFIED,
+                notice_days_warn,
+                notice_days_notify - notice_days_warn,
+            )
 
 
             # Finally, delete domains inactive for `inactive_days + notice_days_notify` days if status has not changed
             # Finally, delete domains inactive for `inactive_days + notice_days_notify` days if status has not changed
             self.delete_domains(fresh_days + notice_days_notify)
             self.delete_domains(fresh_days + notice_days_notify)
         except Exception as e:
         except Exception as e:
-            subject = 'Renewal Exception!'
-            message = f'{type(e)}\n\n{str(e)}'
-            print(f'Chores exception: {type(e)}, {str(e)}')
-            mail_admins(subject, message, connection=get_connection('django.core.mail.backends.smtp.EmailBackend'))
+            subject = "Renewal Exception!"
+            message = f"{type(e)}\n\n{str(e)}"
+            print(f"Chores exception: {type(e)}, {str(e)}")
+            mail_admins(
+                subject,
+                message,
+                connection=get_connection(
+                    "django.core.mail.backends.smtp.EmailBackend"
+                ),
+            )

+ 28 - 17
api/desecapi/management/commands/stop-abuse.py

@@ -7,49 +7,60 @@ from desecapi.pdns_change_tracker import PDNSChangeTracker
 
 
 
 
 class Command(BaseCommand):
 class Command(BaseCommand):
-    help = 'Removes all DNS records from domains given either by name or by email address of their owner. ' \
-           'Locks all implicated user accounts.'
+    help = (
+        "Removes all DNS records from domains given either by name or by email address of their owner. "
+        "Locks all implicated user accounts."
+    )
 
 
     def add_arguments(self, parser):
     def add_arguments(self, parser):
-        parser.add_argument('names', nargs='*',
-                            help='Domain(s) and User(s) to truncate and disable identified by name and email addresses')
+        parser.add_argument(
+            "names",
+            nargs="*",
+            help="Domain(s) and User(s) to truncate and disable identified by name and email addresses",
+        )
 
 
     def handle(self, *args, **options):
     def handle(self, *args, **options):
         with PDNSChangeTracker():
         with PDNSChangeTracker():
             # domains to truncate: all domains given and all domains belonging to a user given
             # domains to truncate: all domains given and all domains belonging to a user given
             domains = Domain.objects.filter(
             domains = Domain.objects.filter(
-                Q(name__in=options['names']) |
-                Q(owner__email__in=options['names'])
+                Q(name__in=options["names"]) | Q(owner__email__in=options["names"])
             )
             )
-            domain_names = domains.distinct().values_list('name', flat=True)
+            domain_names = domains.distinct().values_list("name", flat=True)
 
 
             # users to lock: all associated with any of the domains and all given
             # users to lock: all associated with any of the domains and all given
             users = User.objects.filter(
             users = User.objects.filter(
-                Q(domains__name__in=options['names']) |
-                Q(email__in=options['names'])
+                Q(domains__name__in=options["names"]) | Q(email__in=options["names"])
             )
             )
-            user_emails = users.distinct().values_list('email', flat=True)
+            user_emails = users.distinct().values_list("email", flat=True)
 
 
             # rrsets to delete: all belonging to (all domains given and all domains belonging to a user given)
             # rrsets to delete: all belonging to (all domains given and all domains belonging to a user given)
             rrsets = RRset.objects.filter(
             rrsets = RRset.objects.filter(
-                Q(domain__name__in=options['names']) |
-                Q(domain__owner__email__in=options['names'])
+                Q(domain__name__in=options["names"])
+                | Q(domain__owner__email__in=options["names"])
             )
             )
 
 
             # Print summary
             # Print summary
-            print(f'Deleting {rrsets.distinct().count()} RRset(s) from {domains.distinct().count()} domain(s); '
-                  f'disabling {users.distinct().count()} associated user account(s).')
+            print(
+                f"Deleting {rrsets.distinct().count()} RRset(s) from {domains.distinct().count()} domain(s); "
+                f"disabling {users.distinct().count()} associated user account(s)."
+            )
 
 
             # Print details
             # Print details
             for d in domain_names:
             for d in domain_names:
-                print(f'Truncating domain {d}')
+                print(f"Truncating domain {d}")
             for e in user_emails:
             for e in user_emails:
-                print(f'Locking user {e}')
+                print(f"Locking user {e}")
 
 
             # delete rrsets and create default NS records
             # delete rrsets and create default NS records
             rrsets.delete()
             rrsets.delete()
             for d in domains:
             for d in domains:
-                RRset.objects.create(domain=d, subname='', type='NS', ttl=3600, contents=settings.DEFAULT_NS)
+                RRset.objects.create(
+                    domain=d,
+                    subname="",
+                    type="NS",
+                    ttl=3600,
+                    contents=settings.DEFAULT_NS,
+                )
 
 
         # lock users
         # lock users
         users.update(is_active=False)
         users.update(is_active=False)

+ 17 - 14
api/desecapi/management/commands/sync-from-pdns.py

@@ -6,31 +6,34 @@ from desecapi.models import Domain, RRset, RR, RR_SET_TYPES_AUTOMATIC
 
 
 
 
 class Command(BaseCommand):
 class Command(BaseCommand):
-    help = 'Import authoritative data from pdns, making the local database consistent with pdns.'
+    help = "Import authoritative data from pdns, making the local database consistent with pdns."
 
 
     def add_arguments(self, parser):
     def add_arguments(self, parser):
-        parser.add_argument('domain-name', nargs='*',
-                            help='Domain name to import. If omitted, will import all domains that are known locally.')
+        parser.add_argument(
+            "domain-name",
+            nargs="*",
+            help="Domain name to import. If omitted, will import all domains that are known locally.",
+        )
 
 
     def handle(self, *args, **options):
     def handle(self, *args, **options):
         domains = Domain.objects.all()
         domains = Domain.objects.all()
 
 
-        if options['domain-name']:
-            domains = domains.filter(name__in=options['domain-name'])
-            domain_names = domains.values_list('name', flat=True)
+        if options["domain-name"]:
+            domains = domains.filter(name__in=options["domain-name"])
+            domain_names = domains.values_list("name", flat=True)
 
 
-            for domain_name in options['domain-name']:
+            for domain_name in options["domain-name"]:
                 if domain_name not in domain_names:
                 if domain_name not in domain_names:
-                    raise CommandError('{} is not a known domain'.format(domain_name))
+                    raise CommandError("{} is not a known domain".format(domain_name))
 
 
         for domain in domains:
         for domain in domains:
-            self.stdout.write('%s ...' % domain.name, ending='')
+            self.stdout.write("%s ..." % domain.name, ending="")
             try:
             try:
                 self._sync_domain(domain)
                 self._sync_domain(domain)
-                self.stdout.write(' synced')
+                self.stdout.write(" synced")
             except Exception as e:
             except Exception as e:
-                self.stdout.write(' failed')
-                msg = 'Error while processing {}: {}'.format(domain.name, e)
+                self.stdout.write(" failed")
+                msg = "Error while processing {}: {}".format(domain.name, e)
                 raise CommandError(msg)
                 raise CommandError(msg)
 
 
     @staticmethod
     @staticmethod
@@ -40,9 +43,9 @@ class Command(BaseCommand):
         rrsets = []
         rrsets = []
         rrs = []
         rrs = []
         for rrset_data in pdns.get_rrset_datas(domain):
         for rrset_data in pdns.get_rrset_datas(domain):
-            if rrset_data['type'] in RR_SET_TYPES_AUTOMATIC:
+            if rrset_data["type"] in RR_SET_TYPES_AUTOMATIC:
                 continue
                 continue
-            records = rrset_data.pop('records')
+            records = rrset_data.pop("records")
             rrset = RRset(**rrset_data)
             rrset = RRset(**rrset_data)
             rrsets.append(rrset)
             rrsets.append(rrset)
             rrs.extend([RR(rrset=rrset, content=record) for record in records])
             rrs.extend([RR(rrset=rrset, content=record) for record in records])

+ 30 - 19
api/desecapi/management/commands/sync-to-pdns.py

@@ -8,39 +8,42 @@ from desecapi.pdns_change_tracker import PDNSChangeTracker
 
 
 
 
 class Command(BaseCommand):
 class Command(BaseCommand):
-    help = 'Sync RRsets from local API database to pdns.'
+    help = "Sync RRsets from local API database to pdns."
 
 
     def add_arguments(self, parser):
     def add_arguments(self, parser):
-        parser.add_argument('domain-name', nargs='*',
-                            help='Domain name to sync. If omitted, will import all API domains.')
+        parser.add_argument(
+            "domain-name",
+            nargs="*",
+            help="Domain name to sync. If omitted, will import all API domains.",
+        )
 
 
     def handle(self, *args, **options):
     def handle(self, *args, **options):
         domains = Domain.objects.all()
         domains = Domain.objects.all()
 
 
-        if options['domain-name']:
-            domains = domains.filter(name__in=options['domain-name'])
-            domain_names = domains.values_list('name', flat=True)
+        if options["domain-name"]:
+            domains = domains.filter(name__in=options["domain-name"])
+            domain_names = domains.values_list("name", flat=True)
 
 
-            for domain_name in options['domain-name']:
+            for domain_name in options["domain-name"]:
                 if domain_name not in domain_names:
                 if domain_name not in domain_names:
-                    raise CommandError('{} is not a known domain'.format(domain_name))
+                    raise CommandError("{} is not a known domain".format(domain_name))
 
 
         catalog_alignment = False
         catalog_alignment = False
         for domain in domains:
         for domain in domains:
-            self.stdout.write('%s ...' % domain.name, ending='')
+            self.stdout.write("%s ..." % domain.name, ending="")
             try:
             try:
                 created = self._sync_domain(domain)
                 created = self._sync_domain(domain)
                 if created:
                 if created:
-                    self.stdout.write(f' created (was missing) ...', ending='')
+                    self.stdout.write(f" created (was missing) ...", ending="")
                     catalog_alignment = True
                     catalog_alignment = True
-                self.stdout.write(' synced')
+                self.stdout.write(" synced")
             except Exception as e:
             except Exception as e:
-                self.stdout.write(' failed')
-                msg = 'Error while processing {}: {}'.format(domain.name, e)
+                self.stdout.write(" failed")
+                msg = "Error while processing {}: {}".format(domain.name, e)
                 raise CommandError(msg)
                 raise CommandError(msg)
 
 
         if catalog_alignment:
         if catalog_alignment:
-            call_command('align-catalog-zone')
+            call_command("align-catalog-zone")
 
 
     @staticmethod
     @staticmethod
     @transaction.atomic
     @transaction.atomic
@@ -60,12 +63,20 @@ class Command(BaseCommand):
             created = True
             created = True
 
 
         # modifications actually merged with additions in CreateUpdateDeleteRRSets
         # modifications actually merged with additions in CreateUpdateDeleteRRSets
-        modifications = {(rrset.type, rrset.subname) for rrset in domain.rrset_set.all()}
-        deletions = {(rrset['type'], rrset['subname']) for rrset in pdns.get_rrset_datas(domain)} - modifications
-        deletions.discard(('SOA', ''))  # do not remove SOA record
+        modifications = {
+            (rrset.type, rrset.subname) for rrset in domain.rrset_set.all()
+        }
+        deletions = {
+            (rrset["type"], rrset["subname"]) for rrset in pdns.get_rrset_datas(domain)
+        } - modifications
+        deletions.discard(("SOA", ""))  # do not remove SOA record
 
 
         # Update zone on nslord, propagate to nsmaster
         # Update zone on nslord, propagate to nsmaster
-        PDNSChangeTracker.CreateUpdateDeleteRRSets(domain.name, set(), modifications, deletions).pdns_do()
-        pdns._pdns_put(pdns.NSMASTER, '/zones/{}/axfr-retrieve'.format(pdns.pdns_id(domain.name)))
+        PDNSChangeTracker.CreateUpdateDeleteRRSets(
+            domain.name, set(), modifications, deletions
+        ).pdns_do()
+        pdns._pdns_put(
+            pdns.NSMASTER, "/zones/{}/axfr-retrieve".format(pdns.pdns_id(domain.name))
+        )
 
 
         return created
         return created

+ 42 - 13
api/desecapi/metrics.py

@@ -16,28 +16,57 @@ def set_histogram(name, *args, **kwargs):
 
 
 
 
 # models.py metrics
 # models.py metrics
-set_counter('desecapi_captcha_content_created', 'number of times captcha content created', ['kind'])
-set_counter('desecapi_autodelegation_created', 'number of autodelegations added')
-set_counter('desecapi_autodelegation_deleted', 'number of autodelegations deleted')
-set_histogram('desecapi_messages_queued', 'number of emails queued', ['reason', 'user', 'lane'],
-              buckets=[0, 1, float("inf")])
+set_counter(
+    "desecapi_captcha_content_created",
+    "number of times captcha content created",
+    ["kind"],
+)
+set_counter("desecapi_autodelegation_created", "number of autodelegations added")
+set_counter("desecapi_autodelegation_deleted", "number of autodelegations deleted")
+set_histogram(
+    "desecapi_messages_queued",
+    "number of emails queued",
+    ["reason", "user", "lane"],
+    buckets=[0, 1, float("inf")],
+)
 
 
 # views.py metrics
 # views.py metrics
-set_counter('desecapi_dynDNS12_domain_not_found', 'number of times dynDNS12 domain is not found')
+set_counter(
+    "desecapi_dynDNS12_domain_not_found", "number of times dynDNS12 domain is not found"
+)
 
 
 # crypto.py metrics
 # crypto.py metrics
-set_counter('desecapi_key_encryption_success', 'number of times key encryption was successful', ['context'])
-set_counter('desecapi_key_decryption_success', 'number of times key decryption was successful', ['context'])
+set_counter(
+    "desecapi_key_encryption_success",
+    "number of times key encryption was successful",
+    ["context"],
+)
+set_counter(
+    "desecapi_key_decryption_success",
+    "number of times key decryption was successful",
+    ["context"],
+)
 
 
 # exception_handlers.py metrics
 # exception_handlers.py metrics
-set_counter('desecapi_database_unavailable', 'number of times database was unavailable')
+set_counter("desecapi_database_unavailable", "number of times database was unavailable")
 
 
 # pdns.py metrics
 # pdns.py metrics
-set_counter('desecapi_pdns_request_success', 'number of times pdns request was successful', ['method', 'status'])
-set_counter('desecapi_pdns_keys_fetched', 'number of times pdns keys were fetched')
+set_counter(
+    "desecapi_pdns_request_success",
+    "number of times pdns request was successful",
+    ["method", "status"],
+)
+set_counter("desecapi_pdns_keys_fetched", "number of times pdns keys were fetched")
 
 
 # pdns_change_tracker.py metrics
 # pdns_change_tracker.py metrics
-set_counter('desecapi_pdns_catalog_updated', 'number of times pdns catalog was updated successfully')
+set_counter(
+    "desecapi_pdns_catalog_updated",
+    "number of times pdns catalog was updated successfully",
+)
 
 
 # throttling.py metrics
 # throttling.py metrics
-set_counter('desecapi_throttle_failure', 'number of requests throttled', ['method', 'scope', 'user', 'bucket'])
+set_counter(
+    "desecapi_throttle_failure",
+    "number of requests throttled",
+    ["method", "scope", "user", "bucket"],
+)

+ 371 - 100
api/desecapi/migrations/0001_initial_squashed_again.py

@@ -13,198 +13,469 @@ class Migration(migrations.Migration):
 
 
     initial = True
     initial = True
 
 
-    dependencies = [
-    ]
+    dependencies = []
 
 
     operations = [
     operations = [
         migrations.CreateModel(
         migrations.CreateModel(
-            name='User',
+            name="User",
             fields=[
             fields=[
-                ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
-                ('password', models.CharField(max_length=128, verbose_name='password')),
-                ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
-                ('email', models.EmailField(max_length=191, unique=True, verbose_name='email address')),
-                ('is_active', models.BooleanField(default=True)),
-                ('is_admin', models.BooleanField(default=False)),
-                ('created', models.DateTimeField(auto_now_add=True)),
-                ('limit_domains', models.IntegerField(blank=True, default=desecapi.models.User._limit_domains_default, null=True)),
+                (
+                    "id",
+                    models.UUIDField(
+                        default=uuid.uuid4,
+                        editable=False,
+                        primary_key=True,
+                        serialize=False,
+                    ),
+                ),
+                ("password", models.CharField(max_length=128, verbose_name="password")),
+                (
+                    "last_login",
+                    models.DateTimeField(
+                        blank=True, null=True, verbose_name="last login"
+                    ),
+                ),
+                (
+                    "email",
+                    models.EmailField(
+                        max_length=191, unique=True, verbose_name="email address"
+                    ),
+                ),
+                ("is_active", models.BooleanField(default=True)),
+                ("is_admin", models.BooleanField(default=False)),
+                ("created", models.DateTimeField(auto_now_add=True)),
+                (
+                    "limit_domains",
+                    models.IntegerField(
+                        blank=True,
+                        default=desecapi.models.User._limit_domains_default,
+                        null=True,
+                    ),
+                ),
             ],
             ],
             options={
             options={
-                'abstract': False,
+                "abstract": False,
             },
             },
         ),
         ),
         migrations.CreateModel(
         migrations.CreateModel(
-            name='Domain',
+            name="Domain",
             fields=[
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('created', models.DateTimeField(auto_now_add=True)),
-                ('name', models.CharField(max_length=191, unique=True, validators=[desecapi.models.validate_lower, django.core.validators.RegexValidator(code='invalid_domain_name', flags=re.RegexFlag['IGNORECASE'], message='Domain names must be labels separated by dots. Labels may consist of up to 63 letters, digits, hyphens, and underscores. The last label may not contain an underscore.', regex='^(([a-z0-9_-]{1,63})\\.)*[a-z0-9-]{1,63}$')])),
-                ('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='domains', to=settings.AUTH_USER_MODEL)),
-                ('published', models.DateTimeField(blank=True, null=True)),
-                ('minimum_ttl', models.PositiveIntegerField(default=desecapi.models.Domain._minimum_ttl_default)),
-                ('renewal_changed', models.DateTimeField(auto_now_add=True)),
-                ('renewal_state', models.IntegerField(choices=[(1, 'Fresh'), (2, 'Notified'), (3, 'Warned')], default=1)),
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("created", models.DateTimeField(auto_now_add=True)),
+                (
+                    "name",
+                    models.CharField(
+                        max_length=191,
+                        unique=True,
+                        validators=[
+                            desecapi.models.validate_lower,
+                            django.core.validators.RegexValidator(
+                                code="invalid_domain_name",
+                                flags=re.RegexFlag["IGNORECASE"],
+                                message="Domain names must be labels separated by dots. Labels may consist of up to 63 letters, digits, hyphens, and underscores. The last label may not contain an underscore.",
+                                regex="^(([a-z0-9_-]{1,63})\\.)*[a-z0-9-]{1,63}$",
+                            ),
+                        ],
+                    ),
+                ),
+                (
+                    "owner",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.PROTECT,
+                        related_name="domains",
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+                ("published", models.DateTimeField(blank=True, null=True)),
+                (
+                    "minimum_ttl",
+                    models.PositiveIntegerField(
+                        default=desecapi.models.Domain._minimum_ttl_default
+                    ),
+                ),
+                ("renewal_changed", models.DateTimeField(auto_now_add=True)),
+                (
+                    "renewal_state",
+                    models.IntegerField(
+                        choices=[(1, "Fresh"), (2, "Notified"), (3, "Warned")],
+                        default=1,
+                    ),
+                ),
             ],
             ],
             options={
             options={
-                'ordering': ('created',),
+                "ordering": ("created",),
             },
             },
         ),
         ),
         migrations.CreateModel(
         migrations.CreateModel(
-            name='RRset',
+            name="RRset",
             fields=[
             fields=[
-                ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
-                ('created', models.DateTimeField(auto_now_add=True)),
-                ('touched', models.DateTimeField(auto_now=True)),
-                ('subname', models.CharField(blank=True, max_length=178, validators=[desecapi.models.validate_lower, django.core.validators.RegexValidator(code='invalid_subname', message="Subname can only use (lowercase) a-z, 0-9, ., -, and _, may start with a '*.', or just be '*'.", regex='^([*]|(([*][.])?[a-z0-9_.-]*))$')])),
-                ('type', models.CharField(max_length=10, validators=[desecapi.models.validate_upper, django.core.validators.RegexValidator(code='invalid_type', message='Type must be uppercase alphanumeric and start with a letter.', regex='^[A-Z][A-Z0-9]*$')])),
-                ('ttl', models.PositiveIntegerField()),
-                ('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='desecapi.domain')),
+                (
+                    "id",
+                    models.UUIDField(
+                        default=uuid.uuid4,
+                        editable=False,
+                        primary_key=True,
+                        serialize=False,
+                    ),
+                ),
+                ("created", models.DateTimeField(auto_now_add=True)),
+                ("touched", models.DateTimeField(auto_now=True)),
+                (
+                    "subname",
+                    models.CharField(
+                        blank=True,
+                        max_length=178,
+                        validators=[
+                            desecapi.models.validate_lower,
+                            django.core.validators.RegexValidator(
+                                code="invalid_subname",
+                                message="Subname can only use (lowercase) a-z, 0-9, ., -, and _, may start with a '*.', or just be '*'.",
+                                regex="^([*]|(([*][.])?[a-z0-9_.-]*))$",
+                            ),
+                        ],
+                    ),
+                ),
+                (
+                    "type",
+                    models.CharField(
+                        max_length=10,
+                        validators=[
+                            desecapi.models.validate_upper,
+                            django.core.validators.RegexValidator(
+                                code="invalid_type",
+                                message="Type must be uppercase alphanumeric and start with a letter.",
+                                regex="^[A-Z][A-Z0-9]*$",
+                            ),
+                        ],
+                    ),
+                ),
+                ("ttl", models.PositiveIntegerField()),
+                (
+                    "domain",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="desecapi.domain",
+                    ),
+                ),
             ],
             ],
             options={
             options={
-                'unique_together': {('domain', 'subname', 'type')},
+                "unique_together": {("domain", "subname", "type")},
             },
             },
         ),
         ),
         migrations.CreateModel(
         migrations.CreateModel(
-            name='AuthenticatedAction',
+            name="AuthenticatedAction",
             fields=[
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
             ],
             ],
             options={
             options={
-                'managed': False,
+                "managed": False,
             },
             },
         ),
         ),
         migrations.CreateModel(
         migrations.CreateModel(
-            name='AuthenticatedUserAction',
+            name="AuthenticatedUserAction",
             fields=[
             fields=[
-                ('authenticatedaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticatedaction')),
+                (
+                    "authenticatedaction_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.authenticatedaction",
+                    ),
+                ),
             ],
             ],
             options={
             options={
-                'managed': False,
+                "managed": False,
             },
             },
-            bases=('desecapi.authenticatedaction',),
+            bases=("desecapi.authenticatedaction",),
         ),
         ),
         migrations.CreateModel(
         migrations.CreateModel(
-            name='AuthenticatedDeleteUserAction',
+            name="AuthenticatedDeleteUserAction",
             fields=[
             fields=[
-                ('authenticateduseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticateduseraction')),
+                (
+                    "authenticateduseraction_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.authenticateduseraction",
+                    ),
+                ),
             ],
             ],
             options={
             options={
-                'managed': False,
+                "managed": False,
             },
             },
-            bases=('desecapi.authenticateduseraction',),
+            bases=("desecapi.authenticateduseraction",),
         ),
         ),
         migrations.CreateModel(
         migrations.CreateModel(
-            name='AuthenticatedResetPasswordUserAction',
+            name="AuthenticatedResetPasswordUserAction",
             fields=[
             fields=[
-                ('authenticateduseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticateduseraction')),
-                ('new_password', models.CharField(max_length=128)),
+                (
+                    "authenticateduseraction_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.authenticateduseraction",
+                    ),
+                ),
+                ("new_password", models.CharField(max_length=128)),
             ],
             ],
             options={
             options={
-                'managed': False,
+                "managed": False,
             },
             },
-            bases=('desecapi.authenticateduseraction',),
+            bases=("desecapi.authenticateduseraction",),
         ),
         ),
         migrations.CreateModel(
         migrations.CreateModel(
-            name='Captcha',
+            name="Captcha",
             fields=[
             fields=[
-                ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
-                ('created', models.DateTimeField(auto_now_add=True)),
-                ('content', models.CharField(default=desecapi.models.captcha.captcha_default_content, max_length=24)),
+                (
+                    "id",
+                    models.UUIDField(
+                        default=uuid.uuid4,
+                        editable=False,
+                        primary_key=True,
+                        serialize=False,
+                    ),
+                ),
+                ("created", models.DateTimeField(auto_now_add=True)),
+                (
+                    "content",
+                    models.CharField(
+                        default=desecapi.models.captcha.captcha_default_content,
+                        max_length=24,
+                    ),
+                ),
             ],
             ],
         ),
         ),
         migrations.CreateModel(
         migrations.CreateModel(
-            name='Token',
+            name="Token",
             fields=[
             fields=[
-                ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
-                ('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
-                ('key', models.CharField(db_index=True, max_length=128, unique=True, verbose_name='Key')),
-                ('name', models.CharField(blank=True, max_length=64, verbose_name='Name')),
-                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auth_tokens', to=settings.AUTH_USER_MODEL, verbose_name='User')),
-                ('last_used', models.DateTimeField(blank=True, null=True)),
+                (
+                    "id",
+                    models.UUIDField(
+                        default=uuid.uuid4,
+                        editable=False,
+                        primary_key=True,
+                        serialize=False,
+                    ),
+                ),
+                (
+                    "created",
+                    models.DateTimeField(auto_now_add=True, verbose_name="Created"),
+                ),
+                (
+                    "key",
+                    models.CharField(
+                        db_index=True, max_length=128, unique=True, verbose_name="Key"
+                    ),
+                ),
+                (
+                    "name",
+                    models.CharField(blank=True, max_length=64, verbose_name="Name"),
+                ),
+                (
+                    "user",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="auth_tokens",
+                        to=settings.AUTH_USER_MODEL,
+                        verbose_name="User",
+                    ),
+                ),
+                ("last_used", models.DateTimeField(blank=True, null=True)),
             ],
             ],
             options={
             options={
-                'verbose_name': 'Token',
-                'verbose_name_plural': 'Tokens',
+                "verbose_name": "Token",
+                "verbose_name_plural": "Tokens",
             },
             },
         ),
         ),
         migrations.CreateModel(
         migrations.CreateModel(
-            name='RR',
+            name="RR",
             fields=[
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('created', models.DateTimeField(auto_now_add=True)),
-                ('content', models.CharField(max_length=500)),
-                ('rrset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='records', to='desecapi.rrset')),
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("created", models.DateTimeField(auto_now_add=True)),
+                ("content", models.CharField(max_length=500)),
+                (
+                    "rrset",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="records",
+                        to="desecapi.rrset",
+                    ),
+                ),
             ],
             ],
         ),
         ),
         migrations.CreateModel(
         migrations.CreateModel(
-            name='AuthenticatedActivateUserAction',
+            name="AuthenticatedActivateUserAction",
             fields=[
             fields=[
-                ('authenticateduseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticateduseraction')),
-                ('domain', models.CharField(max_length=191)),
+                (
+                    "authenticateduseraction_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.authenticateduseraction",
+                    ),
+                ),
+                ("domain", models.CharField(max_length=191)),
             ],
             ],
             options={
             options={
-                'managed': False,
+                "managed": False,
             },
             },
-            bases=('desecapi.authenticateduseraction',),
+            bases=("desecapi.authenticateduseraction",),
         ),
         ),
         migrations.CreateModel(
         migrations.CreateModel(
-            name='AuthenticatedChangeEmailUserAction',
+            name="AuthenticatedChangeEmailUserAction",
             fields=[
             fields=[
-                ('authenticateduseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticateduseraction')),
-                ('new_email', models.EmailField(max_length=254)),
+                (
+                    "authenticateduseraction_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.authenticateduseraction",
+                    ),
+                ),
+                ("new_email", models.EmailField(max_length=254)),
             ],
             ],
             options={
             options={
-                'managed': False,
+                "managed": False,
             },
             },
-            bases=('desecapi.authenticateduseraction',),
+            bases=("desecapi.authenticateduseraction",),
         ),
         ),
         migrations.CreateModel(
         migrations.CreateModel(
-            name='AuthenticatedBasicUserAction',
+            name="AuthenticatedBasicUserAction",
             fields=[
             fields=[
-                ('authenticatedaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticatedaction')),
+                (
+                    "authenticatedaction_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.authenticatedaction",
+                    ),
+                ),
             ],
             ],
             options={
             options={
-                'managed': False,
+                "managed": False,
             },
             },
-            bases=('desecapi.authenticatedaction',),
+            bases=("desecapi.authenticatedaction",),
         ),
         ),
         migrations.CreateModel(
         migrations.CreateModel(
-            name='AuthenticatedDomainBasicUserAction',
+            name="AuthenticatedDomainBasicUserAction",
             fields=[
             fields=[
-                ('authenticatedbasicuseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticatedbasicuseraction')),
+                (
+                    "authenticatedbasicuseraction_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.authenticatedbasicuseraction",
+                    ),
+                ),
             ],
             ],
             options={
             options={
-                'managed': False,
+                "managed": False,
             },
             },
-            bases=('desecapi.authenticatedbasicuseraction',),
+            bases=("desecapi.authenticatedbasicuseraction",),
         ),
         ),
         migrations.CreateModel(
         migrations.CreateModel(
-            name='AuthenticatedRenewDomainBasicUserAction',
+            name="AuthenticatedRenewDomainBasicUserAction",
             fields=[
             fields=[
-                ('authenticateddomainbasicuseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticateddomainbasicuseraction')),
+                (
+                    "authenticateddomainbasicuseraction_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.authenticateddomainbasicuseraction",
+                    ),
+                ),
             ],
             ],
             options={
             options={
-                'managed': False,
+                "managed": False,
             },
             },
-            bases=('desecapi.authenticateddomainbasicuseraction',),
+            bases=("desecapi.authenticateddomainbasicuseraction",),
         ),
         ),
         migrations.CreateModel(
         migrations.CreateModel(
-            name='Donation',
+            name="Donation",
             fields=[
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('created', models.DateTimeField(default=desecapi.models.Donation._created_default)),
-                ('name', models.CharField(max_length=255)),
-                ('iban', models.CharField(max_length=34)),
-                ('bic', models.CharField(max_length=11)),
-                ('amount', models.DecimalField(decimal_places=2, max_digits=8)),
-                ('message', models.CharField(blank=True, max_length=255)),
-                ('due', models.DateTimeField(default=desecapi.models.Donation._due_default)),
-                ('mref', models.CharField(default=desecapi.models.Donation._mref_default, max_length=32)),
-                ('email', models.EmailField(blank=True, max_length=255)),
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "created",
+                    models.DateTimeField(
+                        default=desecapi.models.Donation._created_default
+                    ),
+                ),
+                ("name", models.CharField(max_length=255)),
+                ("iban", models.CharField(max_length=34)),
+                ("bic", models.CharField(max_length=11)),
+                ("amount", models.DecimalField(decimal_places=2, max_digits=8)),
+                ("message", models.CharField(blank=True, max_length=255)),
+                (
+                    "due",
+                    models.DateTimeField(default=desecapi.models.Donation._due_default),
+                ),
+                (
+                    "mref",
+                    models.CharField(
+                        default=desecapi.models.Donation._mref_default, max_length=32
+                    ),
+                ),
+                ("email", models.EmailField(blank=True, max_length=255)),
             ],
             ],
             options={
             options={
-                'ordering': ('created',),
-                'managed': False,
+                "ordering": ("created",),
+                "managed": False,
             },
             },
         ),
         ),
     ]
     ]

+ 3 - 3
api/desecapi/migrations/0002_unmanaged_donations.py

@@ -9,12 +9,12 @@ from django.db import migrations
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0001_initial_squashed_again'),
+        ("desecapi", "0001_initial_squashed_again"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.AlterModelOptions(
         migrations.AlterModelOptions(
-            name='donation',
-            options={'managed': False},
+            name="donation",
+            options={"managed": False},
         ),
         ),
     ]
     ]

+ 3 - 3
api/desecapi/migrations/0003_rr_content.py

@@ -6,13 +6,13 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0002_unmanaged_donations'),
+        ("desecapi", "0002_unmanaged_donations"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.AlterField(
         migrations.AlterField(
-            model_name='rr',
-            name='content',
+            model_name="rr",
+            name="content",
             field=models.TextField(),
             field=models.TextField(),
         ),
         ),
     ]
     ]

+ 7 - 4
api/desecapi/migrations/0004_immortal_domains.py

@@ -6,13 +6,16 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0003_rr_content'),
+        ("desecapi", "0003_rr_content"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.AlterField(
         migrations.AlterField(
-            model_name='domain',
-            name='renewal_state',
-            field=models.IntegerField(choices=[(0, 'Immortal'), (1, 'Fresh'), (2, 'Notified'), (3, 'Warned')], default=0),
+            model_name="domain",
+            name="renewal_state",
+            field=models.IntegerField(
+                choices=[(0, "Immortal"), (1, "Fresh"), (2, "Notified"), (3, "Warned")],
+                default=0,
+            ),
         ),
         ),
     ]
     ]

+ 15 - 4
api/desecapi/migrations/0005_subname_validation.py

@@ -8,13 +8,24 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0004_immortal_domains'),
+        ("desecapi", "0004_immortal_domains"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.AlterField(
         migrations.AlterField(
-            model_name='rrset',
-            name='subname',
-            field=models.CharField(blank=True, max_length=178, validators=[desecapi.models.validate_lower, django.core.validators.RegexValidator(code='invalid_subname', message="Subname can only use (lowercase) a-z, 0-9, ., -, and _, may start with a '*.', or just be '*'.", regex='^([*]|(([*][.])?([a-z0-9_-]+[.])*[a-z0-9_-]+))$')]),
+            model_name="rrset",
+            name="subname",
+            field=models.CharField(
+                blank=True,
+                max_length=178,
+                validators=[
+                    desecapi.models.validate_lower,
+                    django.core.validators.RegexValidator(
+                        code="invalid_subname",
+                        message="Subname can only use (lowercase) a-z, 0-9, ., -, and _, may start with a '*.', or just be '*'.",
+                        regex="^([*]|(([*][.])?([a-z0-9_-]+[.])*[a-z0-9_-]+))$",
+                    ),
+                ],
+            ),
         ),
         ),
     ]
     ]

+ 13 - 3
api/desecapi/migrations/0006_cname_exclusivity.py

@@ -9,13 +9,23 @@ import django.db.models.expressions
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0005_subname_validation'),
+        ("desecapi", "0005_subname_validation"),
     ]
     ]
 
 
     operations = [
     operations = [
         BtreeGistExtension(),
         BtreeGistExtension(),
         migrations.AddConstraint(
         migrations.AddConstraint(
-            model_name='rrset',
-            constraint=django.contrib.postgres.constraints.ExclusionConstraint(expressions=[('domain', '='), ('subname', '='), (django.db.models.expressions.RawSQL("int4(type = 'CNAME')", ()), '<>')], name='cname_exclusivity'),
+            model_name="rrset",
+            constraint=django.contrib.postgres.constraints.ExclusionConstraint(
+                expressions=[
+                    ("domain", "="),
+                    ("subname", "="),
+                    (
+                        django.db.models.expressions.RawSQL("int4(type = 'CNAME')", ()),
+                        "<>",
+                    ),
+                ],
+                name="cname_exclusivity",
+            ),
         ),
         ),
     ]
     ]

+ 6 - 4
api/desecapi/migrations/0007_email_citext.py

@@ -8,14 +8,16 @@ from django.db import migrations
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0006_cname_exclusivity'),
+        ("desecapi", "0006_cname_exclusivity"),
     ]
     ]
 
 
     operations = [
     operations = [
         CITextExtension(),
         CITextExtension(),
         migrations.AlterField(
         migrations.AlterField(
-            model_name='user',
-            name='email',
-            field=django.contrib.postgres.fields.citext.CIEmailField(max_length=254, unique=True, verbose_name='email address'),
+            model_name="user",
+            name="email",
+            field=django.contrib.postgres.fields.citext.CIEmailField(
+                max_length=254, unique=True, verbose_name="email address"
+            ),
         ),
         ),
     ]
     ]

+ 5 - 5
api/desecapi/migrations/0008_token_perm_manage_tokens.py

@@ -6,18 +6,18 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0007_email_citext'),
+        ("desecapi", "0007_email_citext"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.AddField(
         migrations.AddField(
-            model_name='token',
-            name='perm_manage_tokens',
+            model_name="token",
+            name="perm_manage_tokens",
             field=models.BooleanField(default=True),
             field=models.BooleanField(default=True),
         ),
         ),
         migrations.AlterField(
         migrations.AlterField(
-            model_name='token',
-            name='perm_manage_tokens',
+            model_name="token",
+            name="perm_manage_tokens",
             field=models.BooleanField(default=False),
             field=models.BooleanField(default=False),
         ),
         ),
     ]
     ]

+ 8 - 4
api/desecapi/migrations/0009_token_allowed_subnets.py

@@ -9,13 +9,17 @@ import netfields.fields
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0008_token_perm_manage_tokens'),
+        ("desecapi", "0008_token_perm_manage_tokens"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.AddField(
         migrations.AddField(
-            model_name='token',
-            name='allowed_subnets',
-            field=django.contrib.postgres.fields.ArrayField(base_field=netfields.fields.CidrAddressField(max_length=43), default=desecapi.models.Token._allowed_subnets_default, size=None),
+            model_name="token",
+            name="allowed_subnets",
+            field=django.contrib.postgres.fields.ArrayField(
+                base_field=netfields.fields.CidrAddressField(max_length=43),
+                default=desecapi.models.Token._allowed_subnets_default,
+                size=None,
+            ),
         ),
         ),
     ]
     ]

+ 19 - 7
api/desecapi/migrations/0010_token_expiration.py

@@ -8,18 +8,30 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0009_token_allowed_subnets'),
+        ("desecapi", "0009_token_allowed_subnets"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.AddField(
         migrations.AddField(
-            model_name='token',
-            name='max_age',
-            field=models.DurationField(default=None, null=True, validators=[django.core.validators.MinValueValidator(datetime.timedelta(0))]),
+            model_name="token",
+            name="max_age",
+            field=models.DurationField(
+                default=None,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(datetime.timedelta(0))
+                ],
+            ),
         ),
         ),
         migrations.AddField(
         migrations.AddField(
-            model_name='token',
-            name='max_unused_period',
-            field=models.DurationField(default=None, null=True, validators=[django.core.validators.MinValueValidator(datetime.timedelta(0))]),
+            model_name="token",
+            name="max_unused_period",
+            field=models.DurationField(
+                default=None,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(datetime.timedelta(0))
+                ],
+            ),
         ),
         ),
     ]
     ]

+ 11 - 7
api/desecapi/migrations/0011_captcha_kind.py

@@ -6,18 +6,22 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0010_token_expiration'),
+        ("desecapi", "0010_token_expiration"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.AddField(
         migrations.AddField(
-            model_name='captcha',
-            name='kind',
-            field=models.CharField(choices=[('image', 'Image'), ('audio', 'Audio')], default='image', max_length=24),
+            model_name="captcha",
+            name="kind",
+            field=models.CharField(
+                choices=[("image", "Image"), ("audio", "Audio")],
+                default="image",
+                max_length=24,
+            ),
         ),
         ),
         migrations.AlterField(
         migrations.AlterField(
-            model_name='captcha',
-            name='content',
-            field=models.CharField(default='', max_length=24),
+            model_name="captcha",
+            name="content",
+            field=models.CharField(default="", max_length=24),
         ),
         ),
     ]
     ]

+ 15 - 4
api/desecapi/migrations/0012_rrset_label_length.py

@@ -8,13 +8,24 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0011_captcha_kind'),
+        ("desecapi", "0011_captcha_kind"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.AlterField(
         migrations.AlterField(
-            model_name='rrset',
-            name='subname',
-            field=models.CharField(blank=True, max_length=178, validators=[desecapi.models.validate_lower, django.core.validators.RegexValidator(code='invalid_subname', message="Subname can only use (lowercase) a-z, 0-9, ., -, and _, may start with a '*.', or just be '*'. Components may not exceed 63 characters.", regex='^([*]|(([*][.])?([a-z0-9_-]{1,63}[.])*[a-z0-9_-]{1,63}))$')]),
+            model_name="rrset",
+            name="subname",
+            field=models.CharField(
+                blank=True,
+                max_length=178,
+                validators=[
+                    desecapi.models.validate_lower,
+                    django.core.validators.RegexValidator(
+                        code="invalid_subname",
+                        message="Subname can only use (lowercase) a-z, 0-9, ., -, and _, may start with a '*.', or just be '*'. Components may not exceed 63 characters.",
+                        regex="^([*]|(([*][.])?([a-z0-9_-]{1,63}[.])*[a-z0-9_-]{1,63}))$",
+                    ),
+                ],
+            ),
         ),
         ),
     ]
     ]

+ 5 - 5
api/desecapi/migrations/0013_user_needs_captcha.py

@@ -6,18 +6,18 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0012_rrset_label_length'),
+        ("desecapi", "0012_rrset_label_length"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.AddField(
         migrations.AddField(
-            model_name='user',
-            name='needs_captcha',
+            model_name="user",
+            name="needs_captcha",
             field=models.BooleanField(default=False),
             field=models.BooleanField(default=False),
         ),
         ),
         migrations.AlterField(
         migrations.AlterField(
-            model_name='user',
-            name='needs_captcha',
+            model_name="user",
+            name="needs_captcha",
             field=models.BooleanField(default=True),
             field=models.BooleanField(default=True),
         ),
         ),
     ]
     ]

+ 5 - 5
api/desecapi/migrations/0014_replication.py

@@ -6,18 +6,18 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0013_user_needs_captcha'),
+        ("desecapi", "0013_user_needs_captcha"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.AddField(
         migrations.AddField(
-            model_name='domain',
-            name='replicated',
+            model_name="domain",
+            name="replicated",
             field=models.DateTimeField(blank=True, null=True),
             field=models.DateTimeField(blank=True, null=True),
         ),
         ),
         migrations.AddField(
         migrations.AddField(
-            model_name='domain',
-            name='replication_duration',
+            model_name="domain",
+            name="replication_duration",
             field=models.DurationField(blank=True, null=True),
             field=models.DurationField(blank=True, null=True),
         ),
         ),
     ]
     ]

+ 3 - 3
api/desecapi/migrations/0015_rrset_touched_index.py

@@ -6,13 +6,13 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0014_replication'),
+        ("desecapi", "0014_replication"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.AlterField(
         migrations.AlterField(
-            model_name='rrset',
-            name='touched',
+            model_name="rrset",
+            name="touched",
             field=models.DateTimeField(auto_now=True, db_index=True),
             field=models.DateTimeField(auto_now=True, db_index=True),
         ),
         ),
     ]
     ]

+ 11 - 7
api/desecapi/migrations/0016_default_auto_field.py

@@ -6,18 +6,22 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0015_rrset_touched_index'),
+        ("desecapi", "0015_rrset_touched_index"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.AlterField(
         migrations.AlterField(
-            model_name='domain',
-            name='id',
-            field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
+            model_name="domain",
+            name="id",
+            field=models.BigAutoField(
+                auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+            ),
         ),
         ),
         migrations.AlterField(
         migrations.AlterField(
-            model_name='rr',
-            name='id',
-            field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
+            model_name="rr",
+            name="id",
+            field=models.BigAutoField(
+                auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+            ),
         ),
         ),
     ]
     ]

+ 8 - 4
api/desecapi/migrations/0017_alter_user_limit_domains.py

@@ -7,13 +7,17 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0016_default_auto_field'),
+        ("desecapi", "0016_default_auto_field"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.AlterField(
         migrations.AlterField(
-            model_name='user',
-            name='limit_domains',
-            field=models.PositiveIntegerField(blank=True, default=desecapi.models.User._limit_domains_default, null=True),
+            model_name="user",
+            name="limit_domains",
+            field=models.PositiveIntegerField(
+                blank=True,
+                default=desecapi.models.User._limit_domains_default,
+                null=True,
+            ),
         ),
         ),
     ]
     ]

+ 69 - 27
api/desecapi/migrations/0018_tokendomainpolicy.py

@@ -9,56 +9,98 @@ import django.db.models.expressions
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0017_alter_user_limit_domains'),
+        ("desecapi", "0017_alter_user_limit_domains"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.AlterField(
         migrations.AlterField(
-            model_name='token',
-            name='user',
-            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
+            model_name="token",
+            name="user",
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
+            ),
         ),
         ),
         migrations.CreateModel(
         migrations.CreateModel(
-            name='TokenDomainPolicy',
+            name="TokenDomainPolicy",
             fields=[
             fields=[
-                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('perm_dyndns', models.BooleanField(default=False)),
-                ('perm_rrsets', models.BooleanField(default=False)),
-                ('domain', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='desecapi.domain')),
-                ('token', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='desecapi.token')),
-                ('token_user', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)),
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("perm_dyndns", models.BooleanField(default=False)),
+                ("perm_rrsets", models.BooleanField(default=False)),
+                (
+                    "domain",
+                    models.ForeignKey(
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="desecapi.domain",
+                    ),
+                ),
+                (
+                    "token",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE, to="desecapi.token"
+                    ),
+                ),
+                (
+                    "token_user",
+                    models.ForeignKey(
+                        db_constraint=False,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="+",
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
             ],
             ],
         ),
         ),
         migrations.AddField(
         migrations.AddField(
-            model_name='token',
-            name='domain_policies',
-            field=models.ManyToManyField(through='desecapi.TokenDomainPolicy', to='desecapi.Domain'),
+            model_name="token",
+            name="domain_policies",
+            field=models.ManyToManyField(
+                through="desecapi.TokenDomainPolicy", to="desecapi.Domain"
+            ),
         ),
         ),
         migrations.AddConstraint(
         migrations.AddConstraint(
-            model_name='tokendomainpolicy',
-            constraint=models.UniqueConstraint(fields=('token', 'domain'), name='unique_entry'),
+            model_name="tokendomainpolicy",
+            constraint=models.UniqueConstraint(
+                fields=("token", "domain"), name="unique_entry"
+            ),
         ),
         ),
         migrations.AddConstraint(
         migrations.AddConstraint(
-            model_name='tokendomainpolicy',
-            constraint=models.UniqueConstraint(condition=models.Q(('domain__isnull', True)), fields=('token',), name='unique_entry_null_domain'),
+            model_name="tokendomainpolicy",
+            constraint=models.UniqueConstraint(
+                condition=models.Q(("domain__isnull", True)),
+                fields=("token",),
+                name="unique_entry_null_domain",
+            ),
         ),
         ),
         # The remaining operations ensure that domain.owner and token.user can't be inconsistent
         # The remaining operations ensure that domain.owner and token.user can't be inconsistent
         migrations.AlterModelOptions(
         migrations.AlterModelOptions(
-            name='token',
+            name="token",
             options={},
             options={},
         ),
         ),
         migrations.AddConstraint(
         migrations.AddConstraint(
-            model_name='token',
-            constraint=models.UniqueConstraint(fields=('id', 'user'), name='unique_id_user'),
+            model_name="token",
+            constraint=models.UniqueConstraint(
+                fields=("id", "user"), name="unique_id_user"
+            ),
         ),
         ),
         migrations.AddConstraint(
         migrations.AddConstraint(
-            model_name='domain',
-            constraint=models.UniqueConstraint(fields=('id', 'owner'), name='unique_id_owner'),
+            model_name="domain",
+            constraint=models.UniqueConstraint(
+                fields=("id", "owner"), name="unique_id_owner"
+            ),
         ),
         ),
         migrations.RunSQL(
         migrations.RunSQL(
-           "ALTER TABLE desecapi_tokendomainpolicy"
-           " ADD FOREIGN KEY ( domain_id, token_user_id ) REFERENCES desecapi_domain ( id, owner_id ),"
-           " ADD FOREIGN KEY ( token_id, token_user_id ) REFERENCES desecapi_token ( id, user_id );",
-           migrations.RunSQL.noop
+            "ALTER TABLE desecapi_tokendomainpolicy"
+            " ADD FOREIGN KEY ( domain_id, token_user_id ) REFERENCES desecapi_domain ( id, owner_id ),"
+            " ADD FOREIGN KEY ( token_id, token_user_id ) REFERENCES desecapi_token ( id, user_id );",
+            migrations.RunSQL.noop,
         ),
         ),
     ]
     ]

+ 6 - 4
api/desecapi/migrations/0019_alter_user_is_active.py

@@ -6,7 +6,9 @@ from django.db import migrations, models
 def forwards_func(apps, schema_editor):
 def forwards_func(apps, schema_editor):
     User = apps.get_model("desecapi", "User")
     User = apps.get_model("desecapi", "User")
     db_alias = schema_editor.connection.alias
     db_alias = schema_editor.connection.alias
-    User.objects.using(db_alias).filter(is_active=False, last_login__isnull=True).update(is_active=None)
+    User.objects.using(db_alias).filter(
+        is_active=False, last_login__isnull=True
+    ).update(is_active=None)
 
 
 
 
 def reverse_func(apps, schema_editor):
 def reverse_func(apps, schema_editor):
@@ -18,13 +20,13 @@ def reverse_func(apps, schema_editor):
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0018_tokendomainpolicy'),
+        ("desecapi", "0018_tokendomainpolicy"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.AlterField(
         migrations.AlterField(
-            model_name='user',
-            name='is_active',
+            model_name="user",
+            name="is_active",
             field=models.BooleanField(default=True, null=True),
             field=models.BooleanField(default=True, null=True),
         ),
         ),
         migrations.RunPython(forwards_func, reverse_func),
         migrations.RunPython(forwards_func, reverse_func),

+ 5 - 5
api/desecapi/migrations/0020_user_email_verified.py

@@ -11,20 +11,20 @@ def forwards_func(apps, schema_editor):
     db_alias = schema_editor.connection.alias
     db_alias = schema_editor.connection.alias
     User.objects.using(db_alias).filter(
     User.objects.using(db_alias).filter(
         Q(is_active=True) | Q(last_login__isnull=False),
         Q(is_active=True) | Q(last_login__isnull=False),
-        created__date__gte=datetime.date(2019, 11, 1)
-    ).update(email_verified=F('created'))
+        created__date__gte=datetime.date(2019, 11, 1),
+    ).update(email_verified=F("created"))
 
 
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0019_alter_user_is_active'),
+        ("desecapi", "0019_alter_user_is_active"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.AddField(
         migrations.AddField(
-            model_name='user',
-            name='email_verified',
+            model_name="user",
+            name="email_verified",
             field=models.DateTimeField(blank=True, null=True),
             field=models.DateTimeField(blank=True, null=True),
         ),
         ),
         migrations.RunPython(forwards_func, migrations.RunPython.noop),
         migrations.RunPython(forwards_func, migrations.RunPython.noop),

+ 15 - 5
api/desecapi/migrations/0021_authenticatednoopuseraction.py

@@ -7,18 +7,28 @@ import django.db.models.deletion
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0020_user_email_verified'),
+        ("desecapi", "0020_user_email_verified"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.CreateModel(
         migrations.CreateModel(
-            name='AuthenticatedNoopUserAction',
+            name="AuthenticatedNoopUserAction",
             fields=[
             fields=[
-                ('authenticateduseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticateduseraction')),
+                (
+                    "authenticateduseraction_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.authenticateduseraction",
+                    ),
+                ),
             ],
             ],
             options={
             options={
-                'managed': False,
+                "managed": False,
             },
             },
-            bases=('desecapi.authenticateduseraction',),
+            bases=("desecapi.authenticateduseraction",),
         ),
         ),
     ]
     ]

+ 3 - 3
api/desecapi/migrations/0022_user_outreach_preference.py

@@ -6,13 +6,13 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0021_authenticatednoopuseraction'),
+        ("desecapi", "0021_authenticatednoopuseraction"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.AddField(
         migrations.AddField(
-            model_name='user',
-            name='outreach_preference',
+            model_name="user",
+            name="outreach_preference",
             field=models.BooleanField(default=True),
             field=models.BooleanField(default=True),
         ),
         ),
     ]
     ]

+ 15 - 5
api/desecapi/migrations/0023_authenticatedemailuseraction.py

@@ -7,18 +7,28 @@ import django.db.models.deletion
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0022_user_outreach_preference'),
+        ("desecapi", "0022_user_outreach_preference"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.CreateModel(
         migrations.CreateModel(
-            name='AuthenticatedEmailUserAction',
+            name="AuthenticatedEmailUserAction",
             fields=[
             fields=[
-                ('authenticatedbasicuseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticatedbasicuseraction')),
+                (
+                    "authenticatedbasicuseraction_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.authenticatedbasicuseraction",
+                    ),
+                ),
             ],
             ],
             options={
             options={
-                'managed': False,
+                "managed": False,
             },
             },
-            bases=('desecapi.authenticatedbasicuseraction',),
+            bases=("desecapi.authenticatedbasicuseraction",),
         ),
         ),
     ]
     ]

+ 16 - 6
api/desecapi/migrations/0024_authenticatedchangeoutreachpreferenceuseraction.py

@@ -7,19 +7,29 @@ import django.db.models.deletion
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0023_authenticatedemailuseraction'),
+        ("desecapi", "0023_authenticatedemailuseraction"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.CreateModel(
         migrations.CreateModel(
-            name='AuthenticatedChangeOutreachPreferenceUserAction',
+            name="AuthenticatedChangeOutreachPreferenceUserAction",
             fields=[
             fields=[
-                ('authenticatedemailuseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticatedemailuseraction')),
-                ('outreach_preference', models.BooleanField()),
+                (
+                    "authenticatedemailuseraction_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.authenticatedemailuseraction",
+                    ),
+                ),
+                ("outreach_preference", models.BooleanField()),
             ],
             ],
             options={
             options={
-                'managed': False,
+                "managed": False,
             },
             },
-            bases=('desecapi.authenticatedemailuseraction',),
+            bases=("desecapi.authenticatedemailuseraction",),
         ),
         ),
     ]
     ]

+ 31 - 9
api/desecapi/migrations/0025_alter_token_max_age_alter_token_max_unused_period.py

@@ -9,26 +9,48 @@ def forwards_func(apps, schema_editor):
     max_interval = datetime.timedelta(days=365000)
     max_interval = datetime.timedelta(days=365000)
     Token = apps.get_model("desecapi", "Token")
     Token = apps.get_model("desecapi", "Token")
     db_alias = schema_editor.connection.alias
     db_alias = schema_editor.connection.alias
-    Token.objects.using(db_alias).filter(max_age__gt=max_interval).update(max_age=max_interval)
-    Token.objects.using(db_alias).filter(max_unused_period__gt=max_interval).update(max_unused_period=max_interval)
+    Token.objects.using(db_alias).filter(max_age__gt=max_interval).update(
+        max_age=max_interval
+    )
+    Token.objects.using(db_alias).filter(max_unused_period__gt=max_interval).update(
+        max_unused_period=max_interval
+    )
 
 
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0024_authenticatedchangeoutreachpreferenceuseraction'),
+        ("desecapi", "0024_authenticatedchangeoutreachpreferenceuseraction"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.AlterField(
         migrations.AlterField(
-            model_name='token',
-            name='max_age',
-            field=models.DurationField(default=None, null=True, validators=[django.core.validators.MinValueValidator(datetime.timedelta(0)), django.core.validators.MaxValueValidator(datetime.timedelta(days=365000))]),
+            model_name="token",
+            name="max_age",
+            field=models.DurationField(
+                default=None,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(datetime.timedelta(0)),
+                    django.core.validators.MaxValueValidator(
+                        datetime.timedelta(days=365000)
+                    ),
+                ],
+            ),
         ),
         ),
         migrations.AlterField(
         migrations.AlterField(
-            model_name='token',
-            name='max_unused_period',
-            field=models.DurationField(default=None, null=True, validators=[django.core.validators.MinValueValidator(datetime.timedelta(0)), django.core.validators.MaxValueValidator(datetime.timedelta(days=365000))]),
+            model_name="token",
+            name="max_unused_period",
+            field=models.DurationField(
+                default=None,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(datetime.timedelta(0)),
+                    django.core.validators.MaxValueValidator(
+                        datetime.timedelta(days=365000)
+                    ),
+                ],
+            ),
         ),
         ),
         migrations.RunPython(forwards_func, migrations.RunPython.noop),
         migrations.RunPython(forwards_func, migrations.RunPython.noop),
     ]
     ]

+ 5 - 5
api/desecapi/migrations/0026_remove_domain_replicated_and_more.py

@@ -6,16 +6,16 @@ from django.db import migrations
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0025_alter_token_max_age_alter_token_max_unused_period'),
+        ("desecapi", "0025_alter_token_max_age_alter_token_max_unused_period"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.RemoveField(
         migrations.RemoveField(
-            model_name='domain',
-            name='replicated',
+            model_name="domain",
+            name="replicated",
         ),
         ),
         migrations.RemoveField(
         migrations.RemoveField(
-            model_name='domain',
-            name='replication_duration',
+            model_name="domain",
+            name="replication_duration",
         ),
         ),
     ]
     ]

+ 6 - 6
api/desecapi/migrations/0027_user_credentials_changed.py

@@ -7,25 +7,25 @@ from django.db.models import F
 def forwards_func(apps, schema_editor):
 def forwards_func(apps, schema_editor):
     User = apps.get_model("desecapi", "User")
     User = apps.get_model("desecapi", "User")
     db_alias = schema_editor.connection.alias
     db_alias = schema_editor.connection.alias
-    User.objects.using(db_alias).update(credentials_changed=F('created'))
+    User.objects.using(db_alias).update(credentials_changed=F("created"))
 
 
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('desecapi', '0026_remove_domain_replicated_and_more'),
+        ("desecapi", "0026_remove_domain_replicated_and_more"),
     ]
     ]
 
 
     operations = [
     operations = [
         migrations.AddField(
         migrations.AddField(
-            model_name='user',
-            name='credentials_changed',
+            model_name="user",
+            name="credentials_changed",
             field=models.DateTimeField(auto_now_add=True, null=True),
             field=models.DateTimeField(auto_now_add=True, null=True),
         ),
         ),
         migrations.RunPython(forwards_func, migrations.RunPython.noop),
         migrations.RunPython(forwards_func, migrations.RunPython.noop),
         migrations.AlterField(
         migrations.AlterField(
-            model_name='user',
-            name='credentials_changed',
+            model_name="user",
+            name="credentials_changed",
             field=models.DateTimeField(auto_now_add=True),
             field=models.DateTimeField(auto_now_add=True),
         ),
         ),
     ]
     ]

+ 19 - 11
api/desecapi/models/authenticated_actions.py

@@ -31,13 +31,14 @@ class AuthenticatedAction(models.Model):
     (3) when provided with data that allows instantiation and a valid state hash, take the defined action, possibly with
     (3) when provided with data that allows instantiation and a valid state hash, take the defined action, possibly with
         additional parameters chosen by the third party that do not belong to the verified state.
         additional parameters chosen by the third party that do not belong to the verified state.
     """
     """
+
     _validated = False
     _validated = False
 
 
     class Meta:
     class Meta:
         managed = False
         managed = False
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
-        state = kwargs.pop('state', None)
+        state = kwargs.pop("state", None)
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
         if state is not None:
         if state is not None:
@@ -65,7 +66,7 @@ class AuthenticatedAction(models.Model):
 
 
         :return: List of values to be signed.
         :return: List of values to be signed.
         """
         """
-        name = '.'.join([self.__module__, self.__class__.__qualname__])
+        name = ".".join([self.__module__, self.__class__.__qualname__])
         return [name]
         return [name]
 
 
     @staticmethod
     @staticmethod
@@ -91,7 +92,7 @@ class AuthenticatedAction(models.Model):
 
 
     def act(self):
     def act(self):
         if not self._validated:
         if not self._validated:
-            raise RuntimeError('Action state could not be verified.')
+            raise RuntimeError("Action state could not be verified.")
         return self._act()
         return self._act()
 
 
 
 
@@ -99,7 +100,8 @@ class AuthenticatedBasicUserAction(AuthenticatedAction):
     """
     """
     Abstract AuthenticatedAction involving a user instance.
     Abstract AuthenticatedAction involving a user instance.
     """
     """
-    user = models.ForeignKey('User', on_delete=models.DO_NOTHING)
+
+    user = models.ForeignKey("User", on_delete=models.DO_NOTHING)
 
 
     class Meta:
     class Meta:
         managed = False
         managed = False
@@ -142,13 +144,18 @@ class AuthenticatedUserAction(AuthenticatedBasicUserAction):
     Abstract AuthenticatedBasicUserAction, incorporating the user's id, email, password, and is_active flag into the
     Abstract AuthenticatedBasicUserAction, incorporating the user's id, email, password, and is_active flag into the
     Message Authentication Code state.
     Message Authentication Code state.
     """
     """
+
     class Meta:
     class Meta:
         managed = False
         managed = False
 
 
     def validate_legacy_state(self, value):
     def validate_legacy_state(self, value):
         # NEW: (1) classname, (2) user.id, (3) user.credentials_changed, (4) user.is_active, (7 ...) [subclasses]
         # NEW: (1) classname, (2) user.id, (3) user.credentials_changed, (4) user.is_active, (7 ...) [subclasses]
         # OLD: (1) classname, (2) user.id, (3) user.email, (4) user.password, (5) user.is_active, (6 ...) [subclasses]
         # OLD: (1) classname, (2) user.id, (3) user.email, (4) user.password, (5) user.is_active, (6 ...) [subclasses]
-        legacy_state_fields = self._state_fields[:2] + [self.user.email, self.user.password] + self._state_fields[3:]
+        legacy_state_fields = (
+            self._state_fields[:2]
+            + [self.user.email, self.user.password]
+            + self._state_fields[3:]
+        )
         return value == self.state_of(legacy_state_fields)
         return value == self.state_of(legacy_state_fields)
 
 
     def validate_state(self, value):
     def validate_state(self, value):
@@ -158,7 +165,10 @@ class AuthenticatedUserAction(AuthenticatedBasicUserAction):
 
 
     @property
     @property
     def _state_fields(self):
     def _state_fields(self):
-        return super()._state_fields + [self.user.credentials_changed.isoformat(), self.user.is_active]
+        return super()._state_fields + [
+            self.user.credentials_changed.isoformat(),
+            self.user.is_active,
+        ]
 
 
 
 
 class AuthenticatedActivateUserAction(AuthenticatedUserAction):
 class AuthenticatedActivateUserAction(AuthenticatedUserAction):
@@ -190,7 +200,6 @@ class AuthenticatedChangeEmailUserAction(AuthenticatedUserAction):
 
 
 
 
 class AuthenticatedNoopUserAction(AuthenticatedUserAction):
 class AuthenticatedNoopUserAction(AuthenticatedUserAction):
-
     class Meta:
     class Meta:
         managed = False
         managed = False
 
 
@@ -209,7 +218,6 @@ class AuthenticatedResetPasswordUserAction(AuthenticatedUserAction):
 
 
 
 
 class AuthenticatedDeleteUserAction(AuthenticatedUserAction):
 class AuthenticatedDeleteUserAction(AuthenticatedUserAction):
-
     class Meta:
     class Meta:
         managed = False
         managed = False
 
 
@@ -222,7 +230,8 @@ class AuthenticatedDomainBasicUserAction(AuthenticatedBasicUserAction):
     Abstract AuthenticatedBasicUserAction involving an domain instance, incorporating the domain's id, name as well as
     Abstract AuthenticatedBasicUserAction involving an domain instance, incorporating the domain's id, name as well as
     the owner ID into the Message Authentication Code state.
     the owner ID into the Message Authentication Code state.
     """
     """
-    domain = models.ForeignKey('Domain', on_delete=models.DO_NOTHING)
+
+    domain = models.ForeignKey("Domain", on_delete=models.DO_NOTHING)
 
 
     class Meta:
     class Meta:
         managed = False
         managed = False
@@ -237,7 +246,6 @@ class AuthenticatedDomainBasicUserAction(AuthenticatedBasicUserAction):
 
 
 
 
 class AuthenticatedRenewDomainBasicUserAction(AuthenticatedDomainBasicUserAction):
 class AuthenticatedRenewDomainBasicUserAction(AuthenticatedDomainBasicUserAction):
-
     class Meta:
     class Meta:
         managed = False
         managed = False
 
 
@@ -248,4 +256,4 @@ class AuthenticatedRenewDomainBasicUserAction(AuthenticatedDomainBasicUserAction
     def _act(self):
     def _act(self):
         self.domain.renewal_state = Domain.RenewalState.FRESH
         self.domain.renewal_state = Domain.RenewalState.FRESH
         self.domain.renewal_changed = timezone.now()
         self.domain.renewal_changed = timezone.now()
-        self.domain.save(update_fields=['renewal_state', 'renewal_changed'])
+        self.domain.save(update_fields=["renewal_state", "renewal_changed"])

+ 16 - 12
api/desecapi/models/base.py

@@ -6,25 +6,29 @@ from django.core.validators import RegexValidator
 
 
 def validate_lower(value):
 def validate_lower(value):
     if value != value.lower():
     if value != value.lower():
-        raise ValidationError('Invalid value (not lowercase): %(value)s',
-                              code='invalid',
-                              params={'value': value})
+        raise ValidationError(
+            "Invalid value (not lowercase): %(value)s",
+            code="invalid",
+            params={"value": value},
+        )
 
 
 
 
 def validate_upper(value):
 def validate_upper(value):
     if value != value.upper():
     if value != value.upper():
-        raise ValidationError('Invalid value (not uppercase): %(value)s',
-                              code='invalid',
-                              params={'value': value})
+        raise ValidationError(
+            "Invalid value (not uppercase): %(value)s",
+            code="invalid",
+            params={"value": value},
+        )
 
 
 
 
 validate_domain_name = [
 validate_domain_name = [
     validate_lower,
     validate_lower,
     RegexValidator(
     RegexValidator(
-        regex=r'^(([a-z0-9_-]{1,63})\.)*[a-z0-9-]{1,63}$',
-        message='Domain names must be labels separated by dots. Labels may consist of up to 63 letters, digits, '
-                'hyphens, and underscores. The last label may not contain an underscore.',
-        code='invalid_domain_name',
-        flags=re.IGNORECASE
-    )
+        regex=r"^(([a-z0-9_-]{1,63})\.)*[a-z0-9-]{1,63}$",
+        message="Domain names must be labels separated by dots. Labels may consist of up to 63 letters, digits, "
+        "hyphens, and underscores. The last label may not contain an underscore.",
+        code="invalid_domain_name",
+        flags=re.IGNORECASE,
+    ),
 ]
 ]

+ 10 - 10
api/desecapi/models/captcha.py

@@ -14,24 +14,25 @@ from desecapi import metrics
 
 
 def captcha_default_content(kind: str) -> str:
 def captcha_default_content(kind: str) -> str:
     if kind == Captcha.Kind.IMAGE:
     if kind == Captcha.Kind.IMAGE:
-        alphabet = (string.ascii_uppercase + string.digits).translate({ord(c): None for c in 'IO0'})
+        alphabet = (string.ascii_uppercase + string.digits).translate(
+            {ord(c): None for c in "IO0"}
+        )
         length = 5
         length = 5
     elif kind == Captcha.Kind.AUDIO:
     elif kind == Captcha.Kind.AUDIO:
         alphabet = string.digits
         alphabet = string.digits
         length = 8
         length = 8
     else:
     else:
-        raise ValueError(f'Unknown Captcha kind: {kind}')
+        raise ValueError(f"Unknown Captcha kind: {kind}")
 
 
-    content = ''.join([secrets.choice(alphabet) for _ in range(length)])
-    metrics.get('desecapi_captcha_content_created').labels(kind).inc()
+    content = "".join([secrets.choice(alphabet) for _ in range(length)])
+    metrics.get("desecapi_captcha_content_created").labels(kind).inc()
     return content
     return content
 
 
 
 
-class Captcha(ExportModelOperationsMixin('Captcha'), models.Model):
-
+class Captcha(ExportModelOperationsMixin("Captcha"), models.Model):
     class Kind(models.TextChoices):
     class Kind(models.TextChoices):
-        IMAGE = 'image'
-        AUDIO = 'audio'
+        IMAGE = "image"
+        AUDIO = "audio"
 
 
     id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
     id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
     created = models.DateTimeField(auto_now_add=True)
     created = models.DateTimeField(auto_now_add=True)
@@ -48,6 +49,5 @@ class Captcha(ExportModelOperationsMixin('Captcha'), models.Model):
         self.delete()
         self.delete()
         return (
         return (
             str(solution).upper().strip() == self.content  # solution correct
             str(solution).upper().strip() == self.content  # solution correct
-            and
-            age <= settings.CAPTCHA_VALIDITY_PERIOD  # not expired
+            and age <= settings.CAPTCHA_VALIDITY_PERIOD  # not expired
         )
         )

+ 116 - 54
api/desecapi/models/domains.py

@@ -21,23 +21,27 @@ from .base import validate_domain_name
 from .records import RRset
 from .records import RRset
 
 
 
 
-psl = psl_dns.PSL(resolver=settings.PSL_RESOLVER, timeout=.5)
+psl = psl_dns.PSL(resolver=settings.PSL_RESOLVER, timeout=0.5)
 
 
 
 
 class DomainManager(Manager):
 class DomainManager(Manager):
     def filter_qname(self, qname: str, **kwargs) -> models.query.QuerySet:
     def filter_qname(self, qname: str, **kwargs) -> models.query.QuerySet:
-        qs = self.annotate(name_length=Length('name'))  # callers expect this to be present after returning
+        qs = self.annotate(
+            name_length=Length("name")
+        )  # callers expect this to be present after returning
         try:
         try:
-            Domain._meta.get_field('name').run_validators(qname.removeprefix('*.').lower())
+            Domain._meta.get_field("name").run_validators(
+                qname.removeprefix("*.").lower()
+            )
         except ValidationError:
         except ValidationError:
             return qs.none()
             return qs.none()
         return qs.annotate(
         return qs.annotate(
-            dotted_name=Concat(Value('.'), 'name', output_field=CharField()),
-            dotted_qname=Value(f'.{qname}', output_field=CharField()),
-        ).filter(dotted_qname__endswith=F('dotted_name'), **kwargs)
+            dotted_name=Concat(Value("."), "name", output_field=CharField()),
+            dotted_qname=Value(f".{qname}", output_field=CharField()),
+        ).filter(dotted_qname__endswith=F("dotted_name"), **kwargs)
 
 
 
 
-class Domain(ExportModelOperationsMixin('Domain'), models.Model):
+class Domain(ExportModelOperationsMixin("Domain"), models.Model):
     @staticmethod
     @staticmethod
     def _minimum_ttl_default():
     def _minimum_ttl_default():
         return settings.MINIMUM_TTL_DEFAULT
         return settings.MINIMUM_TTL_DEFAULT
@@ -49,28 +53,36 @@ class Domain(ExportModelOperationsMixin('Domain'), models.Model):
         WARNED = 3
         WARNED = 3
 
 
     created = models.DateTimeField(auto_now_add=True)
     created = models.DateTimeField(auto_now_add=True)
-    name = models.CharField(max_length=191,
-                            unique=True,
-                            validators=validate_domain_name)
-    owner = models.ForeignKey('User', on_delete=models.PROTECT, related_name='domains')
+    name = models.CharField(
+        max_length=191, unique=True, validators=validate_domain_name
+    )
+    owner = models.ForeignKey("User", on_delete=models.PROTECT, related_name="domains")
     published = models.DateTimeField(null=True, blank=True)
     published = models.DateTimeField(null=True, blank=True)
     minimum_ttl = models.PositiveIntegerField(default=_minimum_ttl_default.__func__)
     minimum_ttl = models.PositiveIntegerField(default=_minimum_ttl_default.__func__)
-    renewal_state = models.IntegerField(choices=RenewalState.choices, default=RenewalState.IMMORTAL)
+    renewal_state = models.IntegerField(
+        choices=RenewalState.choices, default=RenewalState.IMMORTAL
+    )
     renewal_changed = models.DateTimeField(auto_now_add=True)
     renewal_changed = models.DateTimeField(auto_now_add=True)
 
 
     _keys = None
     _keys = None
     objects = DomainManager()
     objects = DomainManager()
 
 
     class Meta:
     class Meta:
-        constraints = [models.UniqueConstraint(fields=['id', 'owner'], name='unique_id_owner')]
-        ordering = ('created',)
+        constraints = [
+            models.UniqueConstraint(fields=["id", "owner"], name="unique_id_owner")
+        ]
+        ordering = ("created",)
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
-        if isinstance(kwargs.get('owner'), AnonymousUser):
-            kwargs = {**kwargs, 'owner': None}  # make a copy and override
+        if isinstance(kwargs.get("owner"), AnonymousUser):
+            kwargs = {**kwargs, "owner": None}  # make a copy and override
         # Avoid super().__init__(owner=None, ...) to not mess up *values instantiation in django.db.models.Model.from_db
         # Avoid super().__init__(owner=None, ...) to not mess up *values instantiation in django.db.models.Model.from_db
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
-        if self.pk is None and kwargs.get('renewal_state') is None and self.is_locally_registrable:
+        if (
+            self.pk is None
+            and kwargs.get("renewal_state") is None
+            and self.is_locally_registrable
+        ):
             self.renewal_state = Domain.RenewalState.FRESH
             self.renewal_state = Domain.RenewalState.FRESH
 
 
     @cached_property
     @cached_property
@@ -79,8 +91,8 @@ class Domain(ExportModelOperationsMixin('Domain'), models.Model):
             public_suffix = psl.get_public_suffix(self.name)
             public_suffix = psl.get_public_suffix(self.name)
             is_public_suffix = psl.is_public_suffix(self.name)
             is_public_suffix = psl.is_public_suffix(self.name)
         except (Timeout, NoNameservers):
         except (Timeout, NoNameservers):
-            public_suffix = self.name.rpartition('.')[2]
-            is_public_suffix = ('.' not in self.name)  # TLDs are public suffixes
+            public_suffix = self.name.rpartition(".")[2]
+            is_public_suffix = "." not in self.name  # TLDs are public suffixes
 
 
         if is_public_suffix:
         if is_public_suffix:
             return public_suffix
             return public_suffix
@@ -88,8 +100,12 @@ class Domain(ExportModelOperationsMixin('Domain'), models.Model):
         # Take into account that any of the parent domains could be a local public suffix. To that
         # Take into account that any of the parent domains could be a local public suffix. To that
         # end, identify the longest local public suffix that is actually a suffix of domain_name.
         # end, identify the longest local public suffix that is actually a suffix of domain_name.
         for local_public_suffix in settings.LOCAL_PUBLIC_SUFFIXES:
         for local_public_suffix in settings.LOCAL_PUBLIC_SUFFIXES:
-            has_local_public_suffix_parent = ('.' + self.name).endswith('.' + local_public_suffix)
-            if has_local_public_suffix_parent and len(local_public_suffix) > len(public_suffix):
+            has_local_public_suffix_parent = ("." + self.name).endswith(
+                "." + local_public_suffix
+            )
+            if has_local_public_suffix_parent and len(local_public_suffix) > len(
+                public_suffix
+            ):
                 public_suffix = local_public_suffix
                 public_suffix = local_public_suffix
 
 
         return public_suffix
         return public_suffix
@@ -98,20 +114,29 @@ class Domain(ExportModelOperationsMixin('Domain'), models.Model):
         # Generate a list of all domains connecting this one and its public suffix.
         # Generate a list of all domains connecting this one and its public suffix.
         # If another user owns a zone with one of these names, then the requested
         # If another user owns a zone with one of these names, then the requested
         # domain is unavailable because it is part of the other user's zone.
         # domain is unavailable because it is part of the other user's zone.
-        private_components = self.name.rsplit(self.public_suffix, 1)[0].rstrip('.')
-        private_components = private_components.split('.') if private_components else []
-        private_domains = ['.'.join(private_components[i:]) for i in range(0, len(private_components))]
-        private_domains = [f'{private_domain}.{self.public_suffix}' for private_domain in private_domains]
+        private_components = self.name.rsplit(self.public_suffix, 1)[0].rstrip(".")
+        private_components = private_components.split(".") if private_components else []
+        private_domains = [
+            ".".join(private_components[i:]) for i in range(0, len(private_components))
+        ]
+        private_domains = [
+            f"{private_domain}.{self.public_suffix}"
+            for private_domain in private_domains
+        ]
         assert self.name == next(iter(private_domains), self.public_suffix)
         assert self.name == next(iter(private_domains), self.public_suffix)
 
 
         # Determine whether domain is covered by other users' zones
         # Determine whether domain is covered by other users' zones
-        return Domain.objects.filter(Q(name__in=private_domains) & ~Q(owner=self._owner_or_none)).exists()
+        return Domain.objects.filter(
+            Q(name__in=private_domains) & ~Q(owner=self._owner_or_none)
+        ).exists()
 
 
     def covers_foreign_zone(self):
     def covers_foreign_zone(self):
         # Note: This is not completely accurate: Ideally, we should only consider zones with identical public suffix.
         # Note: This is not completely accurate: Ideally, we should only consider zones with identical public suffix.
         # (If a public suffix lies in between, it's ok.) However, as there could be many descendant zones, the accurate
         # (If a public suffix lies in between, it's ok.) However, as there could be many descendant zones, the accurate
         # check is expensive, so currently not implemented (PSL lookups for each of them).
         # check is expensive, so currently not implemented (PSL lookups for each of them).
-        return Domain.objects.filter(Q(name__endswith=f'.{self.name}') & ~Q(owner=self._owner_or_none)).exists()
+        return Domain.objects.filter(
+            Q(name__endswith=f".{self.name}") & ~Q(owner=self._owner_or_none)
+        ).exists()
 
 
     def is_registrable(self):
     def is_registrable(self):
         """
         """
@@ -119,11 +144,11 @@ class Domain(ExportModelOperationsMixin('Domain'), models.Model):
         Otherwise, True is returned.
         Otherwise, True is returned.
         """
         """
         self.clean()  # ensure .name is a domain name
         self.clean()  # ensure .name is a domain name
-        private_generation = self.name.count('.') - self.public_suffix.count('.')
+        private_generation = self.name.count(".") - self.public_suffix.count(".")
         assert private_generation >= 0
         assert private_generation >= 0
 
 
         # .internal is reserved
         # .internal is reserved
-        if f'.{self.name}'.endswith('.internal'):
+        if f".{self.name}".endswith(".internal"):
             return False
             return False
 
 
         # Public suffixes can only be registered if they are local
         # Public suffixes can only be registered if they are local
@@ -131,8 +156,14 @@ class Domain(ExportModelOperationsMixin('Domain'), models.Model):
             return False
             return False
 
 
         # Disallow _acme-challenge.dedyn.io and the like. Rejects reserved direct children of public suffixes.
         # Disallow _acme-challenge.dedyn.io and the like. Rejects reserved direct children of public suffixes.
-        reserved_prefixes = ('_', 'autoconfig.', 'autodiscover.',)
-        if private_generation == 1 and any(self.name.startswith(prefix) for prefix in reserved_prefixes):
+        reserved_prefixes = (
+            "_",
+            "autoconfig.",
+            "autodiscover.",
+        )
+        if private_generation == 1 and any(
+            self.name.startswith(prefix) for prefix in reserved_prefixes
+        ):
             return False
             return False
 
 
         # Domains covered by another user's zone can't be registered
         # Domains covered by another user's zone can't be registered
@@ -148,29 +179,42 @@ class Domain(ExportModelOperationsMixin('Domain'), models.Model):
     @property
     @property
     def keys(self):
     def keys(self):
         if not self._keys:
         if not self._keys:
-            self._keys = [{**key, 'managed': True} for key in pdns.get_keys(self)]
+            self._keys = [{**key, "managed": True} for key in pdns.get_keys(self)]
             try:
             try:
-                unmanaged_keys = self.rrset_set.get(subname='', type='DNSKEY').records.all()
+                unmanaged_keys = self.rrset_set.get(
+                    subname="", type="DNSKEY"
+                ).records.all()
             except RRset.DoesNotExist:
             except RRset.DoesNotExist:
                 pass
                 pass
             else:
             else:
                 name = dns.name.from_text(self.name)
                 name = dns.name.from_text(self.name)
                 for rr in unmanaged_keys:
                 for rr in unmanaged_keys:
-                    key = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DNSKEY, rr.content)
+                    key = dns.rdata.from_text(
+                        dns.rdataclass.IN, dns.rdatatype.DNSKEY, rr.content
+                    )
                     key_is_sep = key.flags & dns.rdtypes.ANY.DNSKEY.SEP
                     key_is_sep = key.flags & dns.rdtypes.ANY.DNSKEY.SEP
-                    self._keys.append({
-                        'dnskey': rr.content,
-                        'ds': [dns.dnssec.make_ds(name, key, algo).to_text() for algo in (2, 4)] if key_is_sep else [],
-                        'flags': key.flags,  # deprecated
-                        'keytype': None,  # deprecated
-                        'managed': False,
-                    })
+                    self._keys.append(
+                        {
+                            "dnskey": rr.content,
+                            "ds": [
+                                dns.dnssec.make_ds(name, key, algo).to_text()
+                                for algo in (2, 4)
+                            ]
+                            if key_is_sep
+                            else [],
+                            "flags": key.flags,  # deprecated
+                            "keytype": None,  # deprecated
+                            "managed": False,
+                        }
+                    )
         return self._keys
         return self._keys
 
 
     @property
     @property
     def touched(self):
     def touched(self):
         try:
         try:
-            rrset_touched = max(updated for updated in self.rrset_set.values_list('touched', flat=True))
+            rrset_touched = max(
+                updated for updated in self.rrset_set.values_list("touched", flat=True)
+            )
         except ValueError:  # no RRsets (but there should be at least NS)
         except ValueError:  # no RRsets (but there should be at least NS)
             return self.published  # may be None if the domain was never published
             return self.published  # may be None if the domain was never published
         return max(rrset_touched, self.published or rrset_touched)
         return max(rrset_touched, self.published or rrset_touched)
@@ -192,7 +236,7 @@ class Domain(ExportModelOperationsMixin('Domain'), models.Model):
 
 
     @property
     @property
     def _partitioned_name(self):
     def _partitioned_name(self):
-        subname, _, parent_name = self.name.partition('.')
+        subname, _, parent_name = self.name.partition(".")
         return subname, parent_name or None
         return subname, parent_name or None
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
@@ -202,30 +246,48 @@ class Domain(ExportModelOperationsMixin('Domain'), models.Model):
     def update_delegation(self, child_domain: Domain):
     def update_delegation(self, child_domain: Domain):
         child_subname, child_domain_name = child_domain._partitioned_name
         child_subname, child_domain_name = child_domain._partitioned_name
         if self.name != child_domain_name:
         if self.name != child_domain_name:
-            raise ValueError('Cannot update delegation of %s as it is not an immediate child domain of %s.' %
-                             (child_domain.name, self.name))
+            raise ValueError(
+                "Cannot update delegation of %s as it is not an immediate child domain of %s."
+                % (child_domain.name, self.name)
+            )
 
 
         # Always remove delegation so that we con properly recreate it
         # Always remove delegation so that we con properly recreate it
-        for rrset in self.rrset_set.filter(subname=child_subname, type__in=['NS', 'DS']):
+        for rrset in self.rrset_set.filter(
+            subname=child_subname, type__in=["NS", "DS"]
+        ):
             rrset.delete()
             rrset.delete()
 
 
         if child_domain.pk:
         if child_domain.pk:
             # Domain real: (re-)set delegation
             # Domain real: (re-)set delegation
             child_keys = child_domain.keys
             child_keys = child_domain.keys
             if not child_keys:
             if not child_keys:
-                raise APIException('Cannot delegate %s, as it currently has no keys.' % child_domain.name)
-
-            RRset.objects.create(domain=self, subname=child_subname, type='NS', ttl=3600, contents=settings.DEFAULT_NS)
-            RRset.objects.create(domain=self, subname=child_subname, type='DS', ttl=300,
-                                 contents=[ds for k in child_keys for ds in k['ds']])
-            metrics.get('desecapi_autodelegation_created').inc()
+                raise APIException(
+                    "Cannot delegate %s, as it currently has no keys."
+                    % child_domain.name
+                )
+
+            RRset.objects.create(
+                domain=self,
+                subname=child_subname,
+                type="NS",
+                ttl=3600,
+                contents=settings.DEFAULT_NS,
+            )
+            RRset.objects.create(
+                domain=self,
+                subname=child_subname,
+                type="DS",
+                ttl=300,
+                contents=[ds for k in child_keys for ds in k["ds"]],
+            )
+            metrics.get("desecapi_autodelegation_created").inc()
         else:
         else:
             # Domain not real: that's it
             # Domain not real: that's it
-            metrics.get('desecapi_autodelegation_deleted').inc()
+            metrics.get("desecapi_autodelegation_deleted").inc()
 
 
     def delete(self):
     def delete(self):
         ret = super().delete()
         ret = super().delete()
-        logger.warning(f'Domain {self.name} deleted (owner: {self.owner.pk})')
+        logger.warning(f"Domain {self.name} deleted (owner: {self.owner.pk})")
         return ret
         return ret
 
 
     def __str__(self):
     def __str__(self):

+ 1 - 1
api/desecapi/models/donation.py

@@ -8,7 +8,7 @@ from django.utils import timezone
 from django_prometheus.models import ExportModelOperationsMixin
 from django_prometheus.models import ExportModelOperationsMixin
 
 
 
 
-class Donation(ExportModelOperationsMixin('Donation'), models.Model):
+class Donation(ExportModelOperationsMixin("Donation"), models.Model):
     @staticmethod
     @staticmethod
     def _created_default():
     def _created_default():
         return timezone.now()
         return timezone.now()

+ 91 - 52
api/desecapi/models/records.py

@@ -24,26 +24,35 @@ from .base import validate_lower, validate_upper
 # RR set types: the good, the bad, and the ugly
 # RR set types: the good, the bad, and the ugly
 # known, but unsupported types
 # known, but unsupported types
 RR_SET_TYPES_UNSUPPORTED = {
 RR_SET_TYPES_UNSUPPORTED = {
-    'ALIAS',  # Requires signing at the frontend, hence unsupported in desec-stack
-    'IPSECKEY',  # broken in pdns, https://github.com/PowerDNS/pdns/issues/10589 TODO enable with pdns auth >= 4.7.0
-    'KEY',  # Application use restricted by RFC 3445, DNSSEC use replaced by DNSKEY and handled automatically
-    'WKS',  # General usage not recommended, "SHOULD NOT" be used in SMTP (RFC 1123)
+    "ALIAS",  # Requires signing at the frontend, hence unsupported in desec-stack
+    "IPSECKEY",  # broken in pdns, https://github.com/PowerDNS/pdns/issues/10589 TODO enable with pdns auth >= 4.7.0
+    "KEY",  # Application use restricted by RFC 3445, DNSSEC use replaced by DNSKEY and handled automatically
+    "WKS",  # General usage not recommended, "SHOULD NOT" be used in SMTP (RFC 1123)
 }
 }
 # restricted types are managed in use by the API, and cannot directly be modified by the API client
 # restricted types are managed in use by the API, and cannot directly be modified by the API client
 RR_SET_TYPES_AUTOMATIC = {
 RR_SET_TYPES_AUTOMATIC = {
     # corresponding functionality is automatically managed:
     # corresponding functionality is automatically managed:
-    'KEY', 'NSEC', 'NSEC3', 'OPT', 'RRSIG',
+    "KEY",
+    "NSEC",
+    "NSEC3",
+    "OPT",
+    "RRSIG",
     # automatically managed by the API:
     # automatically managed by the API:
-    'NSEC3PARAM', 'SOA'
+    "NSEC3PARAM",
+    "SOA",
 }
 }
 # backend types are types that are the types supported by the backend(s)
 # backend types are types that are the types supported by the backend(s)
 RR_SET_TYPES_BACKEND = pdns.SUPPORTED_RRSET_TYPES
 RR_SET_TYPES_BACKEND = pdns.SUPPORTED_RRSET_TYPES
 # validation types are types supported by the validation backend, currently: dnspython
 # validation types are types supported by the validation backend, currently: dnspython
-RR_SET_TYPES_VALIDATION = set(ANY.__all__) | set(IN.__all__) \
-                          | {'L32', 'L64', 'LP', 'NID'}  # https://github.com/rthalley/dnspython/pull/751
+RR_SET_TYPES_VALIDATION = (
+    set(ANY.__all__) | set(IN.__all__) | {"L32", "L64", "LP", "NID"}
+)  # https://github.com/rthalley/dnspython/pull/751
 # manageable types are directly managed by the API client
 # manageable types are directly managed by the API client
-RR_SET_TYPES_MANAGEABLE = \
-        (RR_SET_TYPES_BACKEND & RR_SET_TYPES_VALIDATION) - RR_SET_TYPES_UNSUPPORTED - RR_SET_TYPES_AUTOMATIC
+RR_SET_TYPES_MANAGEABLE = (
+    (RR_SET_TYPES_BACKEND & RR_SET_TYPES_VALIDATION)
+    - RR_SET_TYPES_UNSUPPORTED
+    - RR_SET_TYPES_AUTOMATIC
+)
 
 
 
 
 class RRsetManager(Manager):
 class RRsetManager(Manager):
@@ -54,34 +63,34 @@ class RRsetManager(Manager):
         return rrset
         return rrset
 
 
 
 
-class RRset(ExportModelOperationsMixin('RRset'), models.Model):
+class RRset(ExportModelOperationsMixin("RRset"), models.Model):
     id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
     id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
     created = models.DateTimeField(auto_now_add=True)
     created = models.DateTimeField(auto_now_add=True)
     touched = models.DateTimeField(auto_now=True, db_index=True)
     touched = models.DateTimeField(auto_now=True, db_index=True)
-    domain = models.ForeignKey('Domain', on_delete=models.CASCADE)
+    domain = models.ForeignKey("Domain", on_delete=models.CASCADE)
     subname = models.CharField(
     subname = models.CharField(
         max_length=178,
         max_length=178,
         blank=True,
         blank=True,
         validators=[
         validators=[
             validate_lower,
             validate_lower,
             validators.RegexValidator(
             validators.RegexValidator(
-                regex=r'^([*]|(([*][.])?([a-z0-9_-]{1,63}[.])*[a-z0-9_-]{1,63}))$',
-                message='Subname can only use (lowercase) a-z, 0-9, ., -, and _, '
-                        'may start with a \'*.\', or just be \'*\'. Components may not exceed 63 characters.',
-                code='invalid_subname'
-            )
-        ]
+                regex=r"^([*]|(([*][.])?([a-z0-9_-]{1,63}[.])*[a-z0-9_-]{1,63}))$",
+                message="Subname can only use (lowercase) a-z, 0-9, ., -, and _, "
+                "may start with a '*.', or just be '*'. Components may not exceed 63 characters.",
+                code="invalid_subname",
+            ),
+        ],
     )
     )
     type = models.CharField(
     type = models.CharField(
         max_length=10,
         max_length=10,
         validators=[
         validators=[
             validate_upper,
             validate_upper,
             validators.RegexValidator(
             validators.RegexValidator(
-                regex=r'^[A-Z][A-Z0-9]*$',
-                message='Type must be uppercase alphanumeric and start with a letter.',
-                code='invalid_type'
-            )
-        ]
+                regex=r"^[A-Z][A-Z0-9]*$",
+                message="Type must be uppercase alphanumeric and start with a letter.",
+                code="invalid_type",
+            ),
+        ],
     )
     )
     ttl = models.PositiveIntegerField()
     ttl = models.PositiveIntegerField()
 
 
@@ -90,10 +99,10 @@ class RRset(ExportModelOperationsMixin('RRset'), models.Model):
     class Meta:
     class Meta:
         constraints = [
         constraints = [
             ExclusionConstraint(
             ExclusionConstraint(
-                name='cname_exclusivity',
+                name="cname_exclusivity",
                 expressions=[
                 expressions=[
-                    ('domain', RangeOperators.EQUAL),
-                    ('subname', RangeOperators.EQUAL),
+                    ("domain", RangeOperators.EQUAL),
+                    ("subname", RangeOperators.EQUAL),
                     (RawSQL("int4(type = 'CNAME')", ()), RangeOperators.NOT_EQUAL),
                     (RawSQL("int4(type = 'CNAME')", ()), RangeOperators.NOT_EQUAL),
                 ],
                 ],
             ),
             ),
@@ -102,7 +111,7 @@ class RRset(ExportModelOperationsMixin('RRset'), models.Model):
 
 
     @staticmethod
     @staticmethod
     def construct_name(subname, domain_name):
     def construct_name(subname, domain_name):
-        return '.'.join(filter(None, [subname, domain_name])) + '.'
+        return ".".join(filter(None, [subname, domain_name])) + "."
 
 
     @property
     @property
     def name(self):
     def name(self):
@@ -127,21 +136,27 @@ class RRset(ExportModelOperationsMixin('RRset'), models.Model):
         errors = []
         errors = []
 
 
         # Singletons
         # Singletons
-        if self.type in ('CNAME', 'DNAME',):
+        if self.type in (
+            "CNAME",
+            "DNAME",
+        ):
             if len(records_presentation_format) > 1:
             if len(records_presentation_format) > 1:
-                errors.append(f'{self.type} RRset cannot have multiple records.')
+                errors.append(f"{self.type} RRset cannot have multiple records.")
 
 
         # Non-apex
         # Non-apex
-        if self.type in ('CNAME', 'DS',):
-            if self.subname == '':
-                errors.append(f'{self.type} RRset cannot have empty subname.')
+        if self.type in (
+            "CNAME",
+            "DS",
+        ):
+            if self.subname == "":
+                errors.append(f"{self.type} RRset cannot have empty subname.")
 
 
-        if self.type in ('DNSKEY',):
-            if self.subname != '':
-                errors.append(f'{self.type} RRset must have empty subname.')
+        if self.type in ("DNSKEY",):
+            if self.subname != "":
+                errors.append(f"{self.type} RRset must have empty subname.")
 
 
         def _error_msg(record, detail):
         def _error_msg(record, detail):
-            return f'Record content of {self.type} {self.name} invalid: \'{record}\': {detail}'
+            return f"Record content of {self.type} {self.name} invalid: '{record}': {detail}"
 
 
         records_canonical_format = set()
         records_canonical_format = set()
         for r in records_presentation_format:
         for r in records_presentation_format:
@@ -151,8 +166,13 @@ class RRset(ExportModelOperationsMixin('RRset'), models.Model):
                 errors.append(_error_msg(r, str(ex)))
                 errors.append(_error_msg(r, str(ex)))
             else:
             else:
                 if r_canonical_format in records_canonical_format:
                 if r_canonical_format in records_canonical_format:
-                    errors.append(_error_msg(r, f'Duplicate record content: this is identical to '
-                                                f'\'{r_canonical_format}\''))
+                    errors.append(
+                        _error_msg(
+                            r,
+                            f"Duplicate record content: this is identical to "
+                            f"'{r_canonical_format}'",
+                        )
+                    )
                 else:
                 else:
                     records_canonical_format.add(r_canonical_format)
                     records_canonical_format.add(r_canonical_format)
 
 
@@ -192,7 +212,12 @@ class RRset(ExportModelOperationsMixin('RRset'), models.Model):
         RR.objects.bulk_create(rrs)  # One INSERT
         RR.objects.bulk_create(rrs)  # One INSERT
 
 
     def __str__(self):
     def __str__(self):
-        return '<RRSet %s domain=%s type=%s subname=%s>' % (self.pk, self.domain.name, self.type, self.subname)
+        return "<RRSet %s domain=%s type=%s subname=%s>" % (
+            self.pk,
+            self.domain.name,
+            self.type,
+            self.subname,
+        )
 
 
 
 
 class RRManager(Manager):
 class RRManager(Manager):
@@ -207,9 +232,9 @@ class RRManager(Manager):
         return ret
         return ret
 
 
 
 
-class RR(ExportModelOperationsMixin('RR'), models.Model):
+class RR(ExportModelOperationsMixin("RR"), models.Model):
     created = models.DateTimeField(auto_now_add=True)
     created = models.DateTimeField(auto_now_add=True)
-    rrset = models.ForeignKey(RRset, on_delete=models.CASCADE, related_name='records')
+    rrset = models.ForeignKey(RRset, on_delete=models.CASCADE, related_name="records")
     content = models.TextField()
     content = models.TextField()
 
 
     objects = RRManager()
     objects = RRManager()
@@ -239,40 +264,54 @@ class RR(ExportModelOperationsMixin('RR'), models.Model):
                 rdclass=rdataclass.IN,
                 rdclass=rdataclass.IN,
                 rdtype=rdtype,
                 rdtype=rdtype,
                 tok=dns.tokenizer.Tokenizer(any_presentation_format),
                 tok=dns.tokenizer.Tokenizer(any_presentation_format),
-                relativize=False
+                relativize=False,
             ).to_digestable()
             ).to_digestable()
 
 
             if len(wire) > 64000:
             if len(wire) > 64000:
-                raise ValidationError(f'Ensure this value has no more than 64000 byte in wire format (it has {len(wire)}).')
+                raise ValidationError(
+                    f"Ensure this value has no more than 64000 byte in wire format (it has {len(wire)})."
+                )
 
 
             parser = dns.wire.Parser(wire, current=0)
             parser = dns.wire.Parser(wire, current=0)
             with parser.restrict_to(len(wire)):
             with parser.restrict_to(len(wire)):
-                rdata = cls.from_wire_parser(rdclass=rdataclass.IN, rdtype=rdtype, parser=parser)
+                rdata = cls.from_wire_parser(
+                    rdclass=rdataclass.IN, rdtype=rdtype, parser=parser
+                )
 
 
             # Convert to canonical presentation format, disable chunking of records.
             # Convert to canonical presentation format, disable chunking of records.
             # Exempt types which have chunksize hardcoded (prevents "got multiple values for keyword argument 'chunksize'").
             # Exempt types which have chunksize hardcoded (prevents "got multiple values for keyword argument 'chunksize'").
-            chunksize_exception_types = (dns.rdatatype.OPENPGPKEY, dns.rdatatype.EUI48, dns.rdatatype.EUI64)
+            chunksize_exception_types = (
+                dns.rdatatype.OPENPGPKEY,
+                dns.rdatatype.EUI48,
+                dns.rdatatype.EUI64,
+            )
             if rdtype in chunksize_exception_types:
             if rdtype in chunksize_exception_types:
                 return rdata.to_text()
                 return rdata.to_text()
             else:
             else:
                 return rdata.to_text(chunksize=0)
                 return rdata.to_text(chunksize=0)
         except binascii.Error:
         except binascii.Error:
             # e.g., odd-length string
             # e.g., odd-length string
-            raise ValueError('Cannot parse hexadecimal or base64 record contents')
+            raise ValueError("Cannot parse hexadecimal or base64 record contents")
         except dns.exception.SyntaxError as e:
         except dns.exception.SyntaxError as e:
             # e.g., A/127.0.0.999
             # e.g., A/127.0.0.999
-            if 'quote' in e.args[0]:
-                raise ValueError(f'Data for {type_} records must be given using quotation marks.')
+            if "quote" in e.args[0]:
+                raise ValueError(
+                    f"Data for {type_} records must be given using quotation marks."
+                )
             else:
             else:
-                raise ValueError(f'Record content for type {type_} malformed: {",".join(e.args)}')
+                raise ValueError(
+                    f'Record content for type {type_} malformed: {",".join(e.args)}'
+                )
         except dns.name.NeedAbsoluteNameOrOrigin:
         except dns.name.NeedAbsoluteNameOrOrigin:
-            raise ValueError('Hostname must be fully qualified (i.e., end in a dot: "example.com.")')
+            raise ValueError(
+                'Hostname must be fully qualified (i.e., end in a dot: "example.com.")'
+            )
         except ValueError as ex:
         except ValueError as ex:
             # e.g., string ("asdf") cannot be parsed into int on base 10
             # e.g., string ("asdf") cannot be parsed into int on base 10
-            raise ValueError(f'Cannot parse record contents: {ex}')
+            raise ValueError(f"Cannot parse record contents: {ex}")
         except Exception as e:
         except Exception as e:
             # TODO see what exceptions raise here for faulty input
             # TODO see what exceptions raise here for faulty input
             raise e
             raise e
 
 
     def __str__(self):
     def __str__(self):
-        return '<RR %s %s rr_set=%s>' % (self.pk, self.content, self.rrset.pk)
+        return "<RR %s %s rr_set=%s>" % (self.pk, self.content, self.rrset.pk)

+ 65 - 31
api/desecapi/models/tokens.py

@@ -18,29 +18,38 @@ from django_prometheus.models import ExportModelOperationsMixin
 from netfields import CidrAddressField, NetManager
 from netfields import CidrAddressField, NetManager
 
 
 
 
-class Token(ExportModelOperationsMixin('Token'), rest_framework.authtoken.models.Token):
+class Token(ExportModelOperationsMixin("Token"), rest_framework.authtoken.models.Token):
     @staticmethod
     @staticmethod
     def _allowed_subnets_default():
     def _allowed_subnets_default():
-        return [ipaddress.IPv4Network('0.0.0.0/0'), ipaddress.IPv6Network('::/0')]
+        return [ipaddress.IPv4Network("0.0.0.0/0"), ipaddress.IPv6Network("::/0")]
 
 
-    _validators = [validators.MinValueValidator(timedelta(0)), validators.MaxValueValidator(timedelta(days=365*1000))]
+    _validators = [
+        validators.MinValueValidator(timedelta(0)),
+        validators.MaxValueValidator(timedelta(days=365 * 1000)),
+    ]
 
 
     id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
     id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
     key = models.CharField("Key", max_length=128, db_index=True, unique=True)
     key = models.CharField("Key", max_length=128, db_index=True, unique=True)
-    user = models.ForeignKey('User', on_delete=models.CASCADE)
-    name = models.CharField('Name', blank=True, max_length=64)
+    user = models.ForeignKey("User", on_delete=models.CASCADE)
+    name = models.CharField("Name", blank=True, max_length=64)
     last_used = models.DateTimeField(null=True, blank=True)
     last_used = models.DateTimeField(null=True, blank=True)
     perm_manage_tokens = models.BooleanField(default=False)
     perm_manage_tokens = models.BooleanField(default=False)
-    allowed_subnets = ArrayField(CidrAddressField(), default=_allowed_subnets_default.__func__)
+    allowed_subnets = ArrayField(
+        CidrAddressField(), default=_allowed_subnets_default.__func__
+    )
     max_age = models.DurationField(null=True, default=None, validators=_validators)
     max_age = models.DurationField(null=True, default=None, validators=_validators)
-    max_unused_period = models.DurationField(null=True, default=None, validators=_validators)
-    domain_policies = models.ManyToManyField('Domain', through='TokenDomainPolicy')
+    max_unused_period = models.DurationField(
+        null=True, default=None, validators=_validators
+    )
+    domain_policies = models.ManyToManyField("Domain", through="TokenDomainPolicy")
 
 
     plain = None
     plain = None
     objects = NetManager()
     objects = NetManager()
 
 
     class Meta:
     class Meta:
-        constraints = [models.UniqueConstraint(fields=['id', 'user'], name='unique_id_user')]
+        constraints = [
+            models.UniqueConstraint(fields=["id", "user"], name="unique_id_user")
+        ]
 
 
     @property
     @property
     def is_valid(self):
     def is_valid(self):
@@ -69,11 +78,17 @@ class Token(ExportModelOperationsMixin('Token'), rest_framework.authtoken.models
 
 
     @staticmethod
     @staticmethod
     def make_hash(plain):
     def make_hash(plain):
-        return make_password(plain, salt='static', hasher='pbkdf2_sha256_iter1')
+        return make_password(plain, salt="static", hasher="pbkdf2_sha256_iter1")
 
 
     def get_policy(self, *, domain=None):
     def get_policy(self, *, domain=None):
-        order_by = F('domain').asc(nulls_last=True)  # default Postgres sorting, but: explicit is better than implicit
-        return self.tokendomainpolicy_set.filter(Q(domain=domain) | Q(domain__isnull=True)).order_by(order_by).first()
+        order_by = F("domain").asc(
+            nulls_last=True
+        )  # default Postgres sorting, but: explicit is better than implicit
+        return (
+            self.tokendomainpolicy_set.filter(Q(domain=domain) | Q(domain__isnull=True))
+            .order_by(order_by)
+            .first()
+        )
 
 
     @transaction.atomic
     @transaction.atomic
     def delete(self):
     def delete(self):
@@ -88,51 +103,56 @@ class Token(ExportModelOperationsMixin('Token'), rest_framework.authtoken.models
 @pgtrigger.register(
 @pgtrigger.register(
     # Ensure that token_user is consistent with token
     # Ensure that token_user is consistent with token
     pgtrigger.Trigger(
     pgtrigger.Trigger(
-        name='token_user',
+        name="token_user",
         operation=pgtrigger.Update | pgtrigger.Insert,
         operation=pgtrigger.Update | pgtrigger.Insert,
         when=pgtrigger.Before,
         when=pgtrigger.Before,
-        func='NEW.token_user_id = (SELECT user_id FROM desecapi_token WHERE id = NEW.token_id); RETURN NEW;',
+        func="NEW.token_user_id = (SELECT user_id FROM desecapi_token WHERE id = NEW.token_id); RETURN NEW;",
     ),
     ),
-
     # Ensure that if there is *any* domain policy for a given token, there is always one with domain=None.
     # Ensure that if there is *any* domain policy for a given token, there is always one with domain=None.
     pgtrigger.Trigger(
     pgtrigger.Trigger(
-        name='default_policy_on_insert',
+        name="default_policy_on_insert",
         operation=pgtrigger.Insert,
         operation=pgtrigger.Insert,
         when=pgtrigger.Before,
         when=pgtrigger.Before,
         # Trigger `condition` arguments (corresponding to WHEN clause) don't support subqueries, so we use `func`
         # Trigger `condition` arguments (corresponding to WHEN clause) don't support subqueries, so we use `func`
         func="IF (NEW.domain_id IS NOT NULL and NOT EXISTS(SELECT * FROM desecapi_tokendomainpolicy WHERE domain_id IS NULL AND token_id = NEW.token_id)) THEN "
         func="IF (NEW.domain_id IS NOT NULL and NOT EXISTS(SELECT * FROM desecapi_tokendomainpolicy WHERE domain_id IS NULL AND token_id = NEW.token_id)) THEN "
-             "  RAISE EXCEPTION 'Cannot insert non-default policy into % table when default policy is not present', TG_TABLE_NAME; "
-             "END IF; RETURN NEW;",
+        "  RAISE EXCEPTION 'Cannot insert non-default policy into % table when default policy is not present', TG_TABLE_NAME; "
+        "END IF; RETURN NEW;",
     ),
     ),
     pgtrigger.Protect(
     pgtrigger.Protect(
-        name='default_policy_on_update',
+        name="default_policy_on_update",
         operation=pgtrigger.Update,
         operation=pgtrigger.Update,
         when=pgtrigger.Before,
         when=pgtrigger.Before,
         condition=pgtrigger.Q(old__domain__isnull=True, new__domain__isnull=False),
         condition=pgtrigger.Q(old__domain__isnull=True, new__domain__isnull=False),
     ),
     ),
     # Ideally, a deferred trigger (https://github.com/Opus10/django-pgtrigger/issues/14). Available in 3.4.0.
     # Ideally, a deferred trigger (https://github.com/Opus10/django-pgtrigger/issues/14). Available in 3.4.0.
     pgtrigger.Trigger(
     pgtrigger.Trigger(
-        name='default_policy_on_delete',
+        name="default_policy_on_delete",
         operation=pgtrigger.Delete,
         operation=pgtrigger.Delete,
         when=pgtrigger.Before,
         when=pgtrigger.Before,
         # Trigger `condition` arguments (corresponding to WHEN clause) don't support subqueries, so we use `func`
         # Trigger `condition` arguments (corresponding to WHEN clause) don't support subqueries, so we use `func`
         func="IF (OLD.domain_id IS NULL and EXISTS(SELECT * FROM desecapi_tokendomainpolicy WHERE domain_id IS NOT NULL AND token_id = OLD.token_id)) THEN "
         func="IF (OLD.domain_id IS NULL and EXISTS(SELECT * FROM desecapi_tokendomainpolicy WHERE domain_id IS NOT NULL AND token_id = OLD.token_id)) THEN "
-             "  RAISE EXCEPTION 'Cannot delete default policy from % table when non-default policy is present', TG_TABLE_NAME; "
-             "END IF; RETURN OLD;",
+        "  RAISE EXCEPTION 'Cannot delete default policy from % table when non-default policy is present', TG_TABLE_NAME; "
+        "END IF; RETURN OLD;",
     ),
     ),
 )
 )
-class TokenDomainPolicy(ExportModelOperationsMixin('TokenDomainPolicy'), models.Model):
+class TokenDomainPolicy(ExportModelOperationsMixin("TokenDomainPolicy"), models.Model):
     token = models.ForeignKey(Token, on_delete=models.CASCADE)
     token = models.ForeignKey(Token, on_delete=models.CASCADE)
-    domain = models.ForeignKey('Domain', on_delete=models.CASCADE, null=True)
+    domain = models.ForeignKey("Domain", on_delete=models.CASCADE, null=True)
     perm_dyndns = models.BooleanField(default=False)
     perm_dyndns = models.BooleanField(default=False)
     perm_rrsets = models.BooleanField(default=False)
     perm_rrsets = models.BooleanField(default=False)
     # Token user, filled via trigger. Used by compound FK constraints to tie domain.owner to token.user (see migration).
     # Token user, filled via trigger. Used by compound FK constraints to tie domain.owner to token.user (see migration).
-    token_user = models.ForeignKey('User', on_delete=models.CASCADE, db_constraint=False, related_name='+')
+    token_user = models.ForeignKey(
+        "User", on_delete=models.CASCADE, db_constraint=False, related_name="+"
+    )
 
 
     class Meta:
     class Meta:
         constraints = [
         constraints = [
-            models.UniqueConstraint(fields=['token', 'domain'], name='unique_entry'),
-            models.UniqueConstraint(fields=['token'], condition=Q(domain__isnull=True), name='unique_entry_null_domain')
+            models.UniqueConstraint(fields=["token", "domain"], name="unique_entry"),
+            models.UniqueConstraint(
+                fields=["token"],
+                condition=Q(domain__isnull=True),
+                name="unique_entry_null_domain",
+            ),
         ]
         ]
 
 
     def clean(self):
     def clean(self):
@@ -140,16 +160,30 @@ class TokenDomainPolicy(ExportModelOperationsMixin('TokenDomainPolicy'), models.
         if self.pk:  # update
         if self.pk:  # update
             # Can't change policy's default status ("domain NULLness") to maintain policy precedence
             # Can't change policy's default status ("domain NULLness") to maintain policy precedence
             if (self.domain is None) != (self.pk == default_policy.pk):
             if (self.domain is None) != (self.pk == default_policy.pk):
-                raise ValidationError({'domain': 'Policy precedence: Cannot disable default policy when others exist.'})
+                raise ValidationError(
+                    {
+                        "domain": "Policy precedence: Cannot disable default policy when others exist."
+                    }
+                )
         else:  # create
         else:  # create
             # Can't violate policy precedence (default policy has to be first)
             # Can't violate policy precedence (default policy has to be first)
             if (self.domain is not None) and (default_policy is None):
             if (self.domain is not None) and (default_policy is None):
-                raise ValidationError({'domain': 'Policy precedence: The first policy must be the default policy.'})
+                raise ValidationError(
+                    {
+                        "domain": "Policy precedence: The first policy must be the default policy."
+                    }
+                )
 
 
     def delete(self):
     def delete(self):
         # Can't delete default policy when others exist
         # Can't delete default policy when others exist
-        if (self.domain is None) and self.token.tokendomainpolicy_set.exclude(domain__isnull=True).exists():
-            raise ValidationError({'domain': "Policy precedence: Can't delete default policy when there exist others."})
+        if (self.domain is None) and self.token.tokendomainpolicy_set.exclude(
+            domain__isnull=True
+        ).exists():
+            raise ValidationError(
+                {
+                    "domain": "Policy precedence: Can't delete default policy when there exist others."
+                }
+            )
         return super().delete()
         return super().delete()
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):

+ 44 - 30
api/desecapi/models/users.py

@@ -20,7 +20,7 @@ class MyUserManager(BaseUserManager):
         Creates and saves a User with the given email and password.
         Creates and saves a User with the given email and password.
         """
         """
         if not email:
         if not email:
-            raise ValueError('Users must have an email address')
+            raise ValueError("Users must have an email address")
 
 
         email = self.normalize_email(email)
         email = self.normalize_email(email)
         user = self.model(email=email, **extra_fields)
         user = self.model(email=email, **extra_fields)
@@ -29,14 +29,14 @@ class MyUserManager(BaseUserManager):
         return user
         return user
 
 
 
 
-class User(ExportModelOperationsMixin('User'), AbstractBaseUser):
+class User(ExportModelOperationsMixin("User"), AbstractBaseUser):
     @staticmethod
     @staticmethod
     def _limit_domains_default():
     def _limit_domains_default():
         return settings.LIMIT_USER_DOMAIN_COUNT_DEFAULT
         return settings.LIMIT_USER_DOMAIN_COUNT_DEFAULT
 
 
     id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
     id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
     email = CIEmailField(
     email = CIEmailField(
-        verbose_name='email address',
+        verbose_name="email address",
         unique=True,
         unique=True,
     )
     )
     email_verified = models.DateTimeField(null=True, blank=True)
     email_verified = models.DateTimeField(null=True, blank=True)
@@ -44,13 +44,15 @@ class User(ExportModelOperationsMixin('User'), AbstractBaseUser):
     is_admin = models.BooleanField(default=False)
     is_admin = models.BooleanField(default=False)
     created = models.DateTimeField(auto_now_add=True)
     created = models.DateTimeField(auto_now_add=True)
     credentials_changed = models.DateTimeField(auto_now_add=True)
     credentials_changed = models.DateTimeField(auto_now_add=True)
-    limit_domains = models.PositiveIntegerField(default=_limit_domains_default.__func__, null=True, blank=True)
+    limit_domains = models.PositiveIntegerField(
+        default=_limit_domains_default.__func__, null=True, blank=True
+    )
     needs_captcha = models.BooleanField(default=True)
     needs_captcha = models.BooleanField(default=True)
     outreach_preference = models.BooleanField(default=True)
     outreach_preference = models.BooleanField(default=True)
 
 
     objects = MyUserManager()
     objects = MyUserManager()
 
 
-    USERNAME_FIELD = 'email'
+    USERNAME_FIELD = "email"
     REQUIRED_FIELDS = []
     REQUIRED_FIELDS = []
 
 
     def get_full_name(self):
     def get_full_name(self):
@@ -92,51 +94,63 @@ class User(ExportModelOperationsMixin('User'), AbstractBaseUser):
         self.validate_unique()
         self.validate_unique()
         self.save()
         self.save()
 
 
-        self.send_email('change-email-confirmation-old-email', recipient=old_email)
+        self.send_email("change-email-confirmation-old-email", recipient=old_email)
 
 
     def change_password(self, raw_password):
     def change_password(self, raw_password):
         self.set_password(raw_password)
         self.set_password(raw_password)
         self.credentials_changed = timezone.now()
         self.credentials_changed = timezone.now()
         self.save()
         self.save()
-        self.send_email('password-change-confirmation')
+        self.send_email("password-change-confirmation")
 
 
     def delete(self):
     def delete(self):
         pk = self.pk
         pk = self.pk
         ret = super().delete()
         ret = super().delete()
-        logger.warning(f'User {pk} deleted')
+        logger.warning(f"User {pk} deleted")
         return ret
         return ret
 
 
-    def send_email(self, reason, context=None, recipient=None, subject=None, template=None):
-        fast_lane = 'email_fast_lane'
-        slow_lane = 'email_slow_lane'
-        immediate_lane = 'email_immediate_lane'
+    def send_email(
+        self, reason, context=None, recipient=None, subject=None, template=None
+    ):
+        fast_lane = "email_fast_lane"
+        slow_lane = "email_slow_lane"
+        immediate_lane = "email_immediate_lane"
         lanes = {
         lanes = {
-            'activate-account': slow_lane,
-            'change-email': slow_lane,
-            'change-email-confirmation-old-email': fast_lane,
-            'change-outreach-preference': slow_lane,
-            'confirm-account': slow_lane,
-            'password-change-confirmation': fast_lane,
-            'reset-password': fast_lane,
-            'delete-account': fast_lane,
-            'domain-dyndns': fast_lane,
-            'renew-domain': immediate_lane,
+            "activate-account": slow_lane,
+            "change-email": slow_lane,
+            "change-email-confirmation-old-email": fast_lane,
+            "change-outreach-preference": slow_lane,
+            "confirm-account": slow_lane,
+            "password-change-confirmation": fast_lane,
+            "reset-password": fast_lane,
+            "delete-account": fast_lane,
+            "domain-dyndns": fast_lane,
+            "renew-domain": immediate_lane,
         }
         }
         if reason not in lanes:
         if reason not in lanes:
-            raise ValueError(f'Cannot send email to user {self.pk} without a good reason: {reason}')
+            raise ValueError(
+                f"Cannot send email to user {self.pk} without a good reason: {reason}"
+            )
 
 
         context = context or {}
         context = context or {}
-        template = template or get_template(f'emails/{reason}/content.txt')
+        template = template or get_template(f"emails/{reason}/content.txt")
         content = template.render(context)
         content = template.render(context)
-        content += f'\nSupport Reference: user_id = {self.pk}\n'
+        content += f"\nSupport Reference: user_id = {self.pk}\n"
 
 
-        logger.warning(f'Queuing email for user account {self.pk} (reason: {reason}, lane: {lanes[reason]})')
+        logger.warning(
+            f"Queuing email for user account {self.pk} (reason: {reason}, lane: {lanes[reason]})"
+        )
         num_queued = EmailMessage(
         num_queued = EmailMessage(
-            subject=(subject or get_template(f'emails/{reason}/subject.txt').render(context)).strip(),
+            subject=(
+                subject or get_template(f"emails/{reason}/subject.txt").render(context)
+            ).strip(),
             body=content,
             body=content,
-            from_email=get_template('emails/from.txt').render(),
+            from_email=get_template("emails/from.txt").render(),
             to=[recipient or self.email],
             to=[recipient or self.email],
-            connection=get_connection(lane=lanes[reason], debug={'user': self.pk, 'reason': reason})
+            connection=get_connection(
+                lane=lanes[reason], debug={"user": self.pk, "reason": reason}
+            ),
         ).send()
         ).send()
-        metrics.get('desecapi_messages_queued').labels(reason, self.pk, lanes[reason]).observe(num_queued)
+        metrics.get("desecapi_messages_queued").labels(
+            reason, self.pk, lanes[reason]
+        ).observe(num_queued)
         return num_queued
         return num_queued

+ 12 - 5
api/desecapi/pagination.py

@@ -10,12 +10,17 @@ class LinkHeaderCursorPagination(CursorPagination):
     described in https://developer.github.com/v3/guides/traversing-with-pagination/
     described in https://developer.github.com/v3/guides/traversing-with-pagination/
     Inspired by the django-rest-framework-link-header-pagination package.
     Inspired by the django-rest-framework-link-header-pagination package.
     """
     """
+
     template = None
     template = None
 
 
     @staticmethod
     @staticmethod
     def construct_headers(pagination_map):
     def construct_headers(pagination_map):
-        links = [f'<{url}>; rel="{label}"' for label, url in pagination_map.items() if url is not None]
-        return {'Link': ', '.join(links)} if links else {}
+        links = [
+            f'<{url}>; rel="{label}"'
+            for label, url in pagination_map.items()
+            if url is not None
+        ]
+        return {"Link": ", ".join(links)} if links else {}
 
 
     def get_paginated_response(self, data):
     def get_paginated_response(self, data):
         pagination_required = self.has_next or self.has_previous
         pagination_required = self.has_next or self.has_previous
@@ -23,13 +28,15 @@ class LinkHeaderCursorPagination(CursorPagination):
             return Response(data)
             return Response(data)
 
 
         url = self.request.build_absolute_uri()
         url = self.request.build_absolute_uri()
-        pagination_map = {'first': replace_query_param(url, self.cursor_query_param, '')}
+        pagination_map = {
+            "first": replace_query_param(url, self.cursor_query_param, "")
+        }
 
 
         if self.cursor_query_param not in self.request.query_params:
         if self.cursor_query_param not in self.request.query_params:
             count = self.queryset.count()
             count = self.queryset.count()
             data = {
             data = {
-                'detail': f'Pagination required. You can query up to {self.page_size} items at a time ({count} total). '
-                          'Please use the `first` page link (see Link header).',
+                "detail": f"Pagination required. You can query up to {self.page_size} items at a time ({count} total). "
+                "Please use the `first` page link (see Link header).",
             }
             }
             headers = self.construct_headers(pagination_map)
             headers = self.construct_headers(pagination_map)
             return Response(data, headers=headers, status=status.HTTP_400_BAD_REQUEST)
             return Response(data, headers=headers, status=status.HTTP_400_BAD_REQUEST)

+ 116 - 55
api/desecapi/pdns.py

@@ -12,15 +12,59 @@ from desecapi.exceptions import PDNSException, RequestEntityTooLarge
 SUPPORTED_RRSET_TYPES = {
 SUPPORTED_RRSET_TYPES = {
     # https://doc.powerdns.com/authoritative/appendices/types.html
     # https://doc.powerdns.com/authoritative/appendices/types.html
     # "major" types
     # "major" types
-    'A', 'AAAA', 'AFSDB', 'ALIAS', 'APL', 'CAA', 'CERT', 'CDNSKEY', 'CDS', 'CNAME', 'CSYNC', 'DNSKEY', 'DNAME', 'DS',
-    'HINFO', 'HTTPS', 'KEY', 'LOC', 'MX', 'NAPTR', 'NS', 'NSEC', 'NSEC3', 'NSEC3PARAM', 'OPENPGPKEY', 'PTR', 'RP',
-    'RRSIG', 'SOA', 'SPF', 'SSHFP', 'SRV', 'SVCB', 'TLSA', 'SMIMEA', 'TXT', 'URI',
-
+    "A",
+    "AAAA",
+    "AFSDB",
+    "ALIAS",
+    "APL",
+    "CAA",
+    "CERT",
+    "CDNSKEY",
+    "CDS",
+    "CNAME",
+    "CSYNC",
+    "DNSKEY",
+    "DNAME",
+    "DS",
+    "HINFO",
+    "HTTPS",
+    "KEY",
+    "LOC",
+    "MX",
+    "NAPTR",
+    "NS",
+    "NSEC",
+    "NSEC3",
+    "NSEC3PARAM",
+    "OPENPGPKEY",
+    "PTR",
+    "RP",
+    "RRSIG",
+    "SOA",
+    "SPF",
+    "SSHFP",
+    "SRV",
+    "SVCB",
+    "TLSA",
+    "SMIMEA",
+    "TXT",
+    "URI",
     # "additional" types, without obsolete ones
     # "additional" types, without obsolete ones
-    'DHCID', 'DLV', 'EUI48', 'EUI64', 'IPSECKEY', 'KX', 'MINFO', 'MR', 'RKEY', 'WKS',
-
+    "DHCID",
+    "DLV",
+    "EUI48",
+    "EUI64",
+    "IPSECKEY",
+    "KX",
+    "MINFO",
+    "MR",
+    "RKEY",
+    "WKS",
     # https://doc.powerdns.com/authoritative/changelog/4.5.html#change-4.5.0-alpha1-New-Features
     # https://doc.powerdns.com/authoritative/changelog/4.5.html#change-4.5.0-alpha1-New-Features
-    'NID', 'L32', 'L64', 'LP'
+    "NID",
+    "L32",
+    "L64",
+    "LP",
 }
 }
 
 
 NSLORD = object()
 NSLORD = object()
@@ -28,22 +72,21 @@ NSMASTER = object()
 
 
 _config = {
 _config = {
     NSLORD: {
     NSLORD: {
-        'base_url': settings.NSLORD_PDNS_API,
-        'headers': {
-            'Accept': 'application/json',
-            'User-Agent': 'desecapi',
-            'X-API-Key': settings.NSLORD_PDNS_API_TOKEN,
-        }
+        "base_url": settings.NSLORD_PDNS_API,
+        "headers": {
+            "Accept": "application/json",
+            "User-Agent": "desecapi",
+            "X-API-Key": settings.NSLORD_PDNS_API_TOKEN,
+        },
     },
     },
     NSMASTER: {
     NSMASTER: {
-        'base_url': settings.NSMASTER_PDNS_API,
-        'headers': {
-            'Accept': 'application/json',
-            'User-Agent': 'desecapi',
-            'X-API-Key': settings.NSMASTER_PDNS_API_TOKEN,
-        }
-    }
-
+        "base_url": settings.NSMASTER_PDNS_API,
+        "headers": {
+            "Accept": "application/json",
+            "User-Agent": "desecapi",
+            "X-API-Key": settings.NSMASTER_PDNS_API_TOKEN,
+        },
+    },
 }
 }
 
 
 
 
@@ -53,62 +96,71 @@ def _pdns_request(method, *, server, path, data=None):
     if data is not None and len(data) > settings.PDNS_MAX_BODY_SIZE:
     if data is not None and len(data) > settings.PDNS_MAX_BODY_SIZE:
         raise RequestEntityTooLarge
         raise RequestEntityTooLarge
 
 
-    r = requests.request(method, _config[server]['base_url'] + path, data=data, headers=_config[server]['headers'])
+    r = requests.request(
+        method,
+        _config[server]["base_url"] + path,
+        data=data,
+        headers=_config[server]["headers"],
+    )
     if r.status_code not in range(200, 300):
     if r.status_code not in range(200, 300):
         raise PDNSException(response=r)
         raise PDNSException(response=r)
-    metrics.get('desecapi_pdns_request_success').labels(method, r.status_code).inc()
+    metrics.get("desecapi_pdns_request_success").labels(method, r.status_code).inc()
     return r
     return r
 
 
 
 
 def _pdns_post(server, path, data):
 def _pdns_post(server, path, data):
-    return _pdns_request('post', server=server, path=path, data=data)
+    return _pdns_request("post", server=server, path=path, data=data)
 
 
 
 
 def _pdns_patch(server, path, data):
 def _pdns_patch(server, path, data):
-    return _pdns_request('patch', server=server, path=path, data=data)
+    return _pdns_request("patch", server=server, path=path, data=data)
 
 
 
 
 def _pdns_get(server, path):
 def _pdns_get(server, path):
-    return _pdns_request('get', server=server, path=path)
+    return _pdns_request("get", server=server, path=path)
 
 
 
 
 def _pdns_put(server, path):
 def _pdns_put(server, path):
-    return _pdns_request('put', server=server, path=path)
+    return _pdns_request("put", server=server, path=path)
 
 
 
 
 def _pdns_delete(server, path):
 def _pdns_delete(server, path):
-    return _pdns_request('delete', server=server, path=path)
+    return _pdns_request("delete", server=server, path=path)
 
 
 
 
 def pdns_id(name):
 def pdns_id(name):
     # See also pdns code, apiZoneNameToId() in ws-api.cc (with the exception of forward slash)
     # See also pdns code, apiZoneNameToId() in ws-api.cc (with the exception of forward slash)
-    if not re.match(r'^[a-zA-Z0-9_.-]+$', name):
-        raise SuspiciousOperation('Invalid hostname ' + name)
+    if not re.match(r"^[a-zA-Z0-9_.-]+$", name):
+        raise SuspiciousOperation("Invalid hostname " + name)
 
 
-    name = name.translate(str.maketrans({'/': '=2F', '_': '=5F'}))
-    return name.rstrip('.') + '.'
+    name = name.translate(str.maketrans({"/": "=2F", "_": "=5F"}))
+    return name.rstrip(".") + "."
 
 
 
 
 def get_keys(domain):
 def get_keys(domain):
     """
     """
     Retrieves a dict representation of the DNSSEC key information
     Retrieves a dict representation of the DNSSEC key information
     """
     """
-    r = _pdns_get(NSLORD, '/zones/%s/cryptokeys' % pdns_id(domain.name))
-    metrics.get('desecapi_pdns_keys_fetched').inc()
+    r = _pdns_get(NSLORD, "/zones/%s/cryptokeys" % pdns_id(domain.name))
+    metrics.get("desecapi_pdns_keys_fetched").inc()
     field_map = {
     field_map = {
-        'dnskey': 'dnskey',
-        'cds': 'ds',
-        'flags': 'flags',  # deprecated
-        'keytype': 'keytype',  # deprecated
+        "dnskey": "dnskey",
+        "cds": "ds",
+        "flags": "flags",  # deprecated
+        "keytype": "keytype",  # deprecated
     }
     }
-    return [{v: key.get(k, []) for k, v in field_map.items()} for key in r.json() if key['published']]
+    return [
+        {v: key.get(k, []) for k, v in field_map.items()}
+        for key in r.json()
+        if key["published"]
+    ]
 
 
 
 
 def get_zone(domain):
 def get_zone(domain):
     """
     """
     Retrieves a dict representation of the zone from pdns
     Retrieves a dict representation of the zone from pdns
     """
     """
-    r = _pdns_get(NSLORD, '/zones/' + pdns_id(domain.name))
+    r = _pdns_get(NSLORD, "/zones/" + pdns_id(domain.name))
 
 
     return r.json()
     return r.json()
 
 
@@ -117,36 +169,45 @@ def get_rrset_datas(domain):
     """
     """
     Retrieves a dict representation of the RRsets in a given zone
     Retrieves a dict representation of the RRsets in a given zone
     """
     """
-    return [{'domain': domain,
-             'subname': rrset['name'][:-(len(domain.name) + 2)],
-             'type': rrset['type'],
-             'records': [record['content'] for record in rrset['records']],
-             'ttl': rrset['ttl']}
-            for rrset in get_zone(domain)['rrsets']]
+    return [
+        {
+            "domain": domain,
+            "subname": rrset["name"][: -(len(domain.name) + 2)],
+            "type": rrset["type"],
+            "records": [record["content"] for record in rrset["records"]],
+            "ttl": rrset["ttl"],
+        }
+        for rrset in get_zone(domain)["rrsets"]
+    ]
 
 
 
 
-def construct_catalog_rrset(zone=None, delete=False, subname=None, qtype='PTR', rdata=None):
+def construct_catalog_rrset(
+    zone=None, delete=False, subname=None, qtype="PTR", rdata=None
+):
     # subname can be generated from zone for convenience; exactly one needs to be given
     # subname can be generated from zone for convenience; exactly one needs to be given
     assert (zone is None) ^ (subname is None)
     assert (zone is None) ^ (subname is None)
     # sanity check: one can't delete an rrset and give record data at the same time
     # sanity check: one can't delete an rrset and give record data at the same time
     assert not (delete and rdata)
     assert not (delete and rdata)
 
 
     if subname is None:
     if subname is None:
-        zone = zone.rstrip('.') + '.'
+        zone = zone.rstrip(".") + "."
         m_unique = sha1(zone.encode()).hexdigest()
         m_unique = sha1(zone.encode()).hexdigest()
-        subname = f'{m_unique}.zones'
+        subname = f"{m_unique}.zones"
 
 
     if rdata is None:
     if rdata is None:
         rdata = zone
         rdata = zone
 
 
     return {
     return {
-        'name': f'{subname}.{settings.CATALOG_ZONE}'.strip('.') + '.',
-        'type': qtype,
-        'ttl': 0,  # as per the specification
-        'changetype': 'REPLACE',
-        'records': [] if delete else [{'content': rdata, 'disabled': False}],
+        "name": f"{subname}.{settings.CATALOG_ZONE}".strip(".") + ".",
+        "type": qtype,
+        "ttl": 0,  # as per the specification
+        "changetype": "REPLACE",
+        "records": [] if delete else [{"content": rdata, "disabled": False}],
     }
     }
 
 
 
 
 def get_serials():
 def get_serials():
-    return {zone['name']: zone['edited_serial'] for zone in _pdns_get(NSMASTER, '/zones').json()}
+    return {
+        zone["name"]: zone["edited_serial"]
+        for zone in _pdns_get(NSMASTER, "/zones").json()
+    }

+ 177 - 95
api/desecapi/pdns_change_tracker.py

@@ -7,8 +7,16 @@ from django.utils import timezone
 
 
 from desecapi import metrics
 from desecapi import metrics
 from desecapi.models import RRset, RR, Domain
 from desecapi.models import RRset, RR, Domain
-from desecapi.pdns import _pdns_post, NSLORD, NSMASTER, _pdns_delete, _pdns_patch, _pdns_put, pdns_id, \
-    construct_catalog_rrset
+from desecapi.pdns import (
+    _pdns_post,
+    NSLORD,
+    NSMASTER,
+    _pdns_delete,
+    _pdns_patch,
+    _pdns_put,
+    pdns_id,
+    construct_catalog_rrset,
+)
 
 
 
 
 class PDNSChangeTracker:
 class PDNSChangeTracker:
@@ -55,7 +63,7 @@ class PDNSChangeTracker:
 
 
         @property
         @property
         def domain_name_normalized(self):
         def domain_name_normalized(self):
-            return self._domain_name + '.'
+            return self._domain_name + "."
 
 
         @property
         @property
         def domain_pdns_id(self):
         def domain_pdns_id(self):
@@ -72,9 +80,16 @@ class PDNSChangeTracker:
             raise NotImplementedError()
             raise NotImplementedError()
 
 
         def update_catalog(self, delete=False):
         def update_catalog(self, delete=False):
-            content = _pdns_patch(NSMASTER, '/zones/' + pdns_id(settings.CATALOG_ZONE),
-                               {'rrsets': [construct_catalog_rrset(zone=self.domain_name, delete=delete)]})
-            metrics.get('desecapi_pdns_catalog_updated').inc()
+            content = _pdns_patch(
+                NSMASTER,
+                "/zones/" + pdns_id(settings.CATALOG_ZONE),
+                {
+                    "rrsets": [
+                        construct_catalog_rrset(zone=self.domain_name, delete=delete)
+                    ]
+                },
+            )
+            metrics.get("desecapi_pdns_catalog_updated").inc()
             return content
             return content
 
 
     class CreateDomain(PDNSChange):
     class CreateDomain(PDNSChange):
@@ -84,38 +99,44 @@ class PDNSChangeTracker:
 
 
         def pdns_do(self):
         def pdns_do(self):
             _pdns_post(
             _pdns_post(
-                NSLORD, '/zones?rrsets=false',
+                NSLORD,
+                "/zones?rrsets=false",
                 {
                 {
-                    'name': self.domain_name_normalized,
-                    'kind': 'MASTER',
-                    'dnssec': True,
-                    'nsec3param': '1 0 0 -',
-                    'nameservers': settings.DEFAULT_NS,
-                    'rrsets': [{
-                        'name': self.domain_name_normalized,
-                        'type': 'SOA',
-                        # SOA RRset TTL: 300 (used as TTL for negative replies including NSEC3 records)
-                        'ttl': 300,
-                        'records': [{
-                            # SOA refresh: 1 day (only needed for nslord --> nsmaster replication after RRSIG rotation)
-                            # SOA retry = 1h
-                            # SOA expire: 4 weeks (all signatures will have expired anyways)
-                            # SOA minimum: 3600 (for CDS, CDNSKEY, DNSKEY, NSEC3PARAM)
-                            'content': 'get.desec.io. get.desec.io. 1 86400 3600 2419200 3600',
-                            'disabled': False
-                        }],
-                    }],
-                }
+                    "name": self.domain_name_normalized,
+                    "kind": "MASTER",
+                    "dnssec": True,
+                    "nsec3param": "1 0 0 -",
+                    "nameservers": settings.DEFAULT_NS,
+                    "rrsets": [
+                        {
+                            "name": self.domain_name_normalized,
+                            "type": "SOA",
+                            # SOA RRset TTL: 300 (used as TTL for negative replies including NSEC3 records)
+                            "ttl": 300,
+                            "records": [
+                                {
+                                    # SOA refresh: 1 day (only needed for nslord --> nsmaster replication after RRSIG rotation)
+                                    # SOA retry = 1h
+                                    # SOA expire: 4 weeks (all signatures will have expired anyways)
+                                    # SOA minimum: 3600 (for CDS, CDNSKEY, DNSKEY, NSEC3PARAM)
+                                    "content": "get.desec.io. get.desec.io. 1 86400 3600 2419200 3600",
+                                    "disabled": False,
+                                }
+                            ],
+                        }
+                    ],
+                },
             )
             )
 
 
             _pdns_post(
             _pdns_post(
-                NSMASTER, '/zones?rrsets=false',
+                NSMASTER,
+                "/zones?rrsets=false",
                 {
                 {
-                    'name': self.domain_name_normalized,
-                    'kind': 'SLAVE',
-                    'masters': [socket.gethostbyname('nslord')],
-                    'master_tsig_key_ids': ['default'],
-                }
+                    "name": self.domain_name_normalized,
+                    "kind": "SLAVE",
+                    "masters": [socket.gethostbyname("nslord")],
+                    "master_tsig_key_ids": ["default"],
+                },
             )
             )
 
 
             self.update_catalog()
             self.update_catalog()
@@ -123,7 +144,8 @@ class PDNSChangeTracker:
         def api_do(self):
         def api_do(self):
             rr_set = RRset(
             rr_set = RRset(
                 domain=Domain.objects.get(name=self.domain_name),
                 domain=Domain.objects.get(name=self.domain_name),
-                type='NS', subname='',
+                type="NS",
+                subname="",
                 ttl=settings.DEFAULT_NS_TTL,
                 ttl=settings.DEFAULT_NS_TTL,
             )
             )
             rr_set.save()
             rr_set.save()
@@ -132,7 +154,7 @@ class PDNSChangeTracker:
             RR.objects.bulk_create(rrs)  # One INSERT
             RR.objects.bulk_create(rrs)  # One INSERT
 
 
         def __str__(self):
         def __str__(self):
-            return 'Create Domain %s' % self.domain_name
+            return "Create Domain %s" % self.domain_name
 
 
     class DeleteDomain(PDNSChange):
     class DeleteDomain(PDNSChange):
         @property
         @property
@@ -140,15 +162,15 @@ class PDNSChangeTracker:
             return False
             return False
 
 
         def pdns_do(self):
         def pdns_do(self):
-            _pdns_delete(NSLORD, '/zones/' + self.domain_pdns_id)
-            _pdns_delete(NSMASTER, '/zones/' + self.domain_pdns_id)
+            _pdns_delete(NSLORD, "/zones/" + self.domain_pdns_id)
+            _pdns_delete(NSMASTER, "/zones/" + self.domain_pdns_id)
             self.update_catalog(delete=True)
             self.update_catalog(delete=True)
 
 
         def api_do(self):
         def api_do(self):
             pass
             pass
 
 
         def __str__(self):
         def __str__(self):
-            return 'Delete Domain %s' % self.domain_name
+            return "Delete Domain %s" % self.domain_name
 
 
     class CreateUpdateDeleteRRSets(PDNSChange):
     class CreateUpdateDeleteRRSets(PDNSChange):
         def __init__(self, domain_name, additions, modifications, deletions):
         def __init__(self, domain_name, additions, modifications, deletions):
@@ -163,44 +185,54 @@ class PDNSChangeTracker:
 
 
         def pdns_do(self):
         def pdns_do(self):
             data = {
             data = {
-                'rrsets':
-                    [
-                        {
-                            'name': RRset.construct_name(subname, self._domain_name),
-                            'type': type_,
-                            'ttl': 1,  # some meaningless integer required by pdns's syntax
-                            'changetype': 'REPLACE',  # don't use "DELETE" due to desec-stack#220, PowerDNS/pdns#7501
-                            'records': []
-                        }
-                        for type_, subname in self._deletions
-                    ] + [
-                        {
-                            'name': RRset.construct_name(subname, self._domain_name),
-                            'type': type_,
-                            'ttl': RRset.objects.values_list('ttl', flat=True).get(domain__name=self._domain_name,
-                                                                                   type=type_, subname=subname),
-                            'changetype': 'REPLACE',
-                            'records': [
-                                {'content': rr.content, 'disabled': False}
-                                for rr in RR.objects.filter(
-                                    rrset__domain__name=self._domain_name,
-                                    rrset__type=type_,
-                                    rrset__subname=subname)
-                            ]
-                        }
-                        for type_, subname in (self._additions | self._modifications) - self._deletions
-                    ]
+                "rrsets": [
+                    {
+                        "name": RRset.construct_name(subname, self._domain_name),
+                        "type": type_,
+                        "ttl": 1,  # some meaningless integer required by pdns's syntax
+                        "changetype": "REPLACE",  # don't use "DELETE" due to desec-stack#220, PowerDNS/pdns#7501
+                        "records": [],
+                    }
+                    for type_, subname in self._deletions
+                ]
+                + [
+                    {
+                        "name": RRset.construct_name(subname, self._domain_name),
+                        "type": type_,
+                        "ttl": RRset.objects.values_list("ttl", flat=True).get(
+                            domain__name=self._domain_name, type=type_, subname=subname
+                        ),
+                        "changetype": "REPLACE",
+                        "records": [
+                            {"content": rr.content, "disabled": False}
+                            for rr in RR.objects.filter(
+                                rrset__domain__name=self._domain_name,
+                                rrset__type=type_,
+                                rrset__subname=subname,
+                            )
+                        ],
+                    }
+                    for type_, subname in (self._additions | self._modifications)
+                    - self._deletions
+                ]
             }
             }
 
 
-            if data['rrsets']:
-                _pdns_patch(NSLORD, '/zones/' + self.domain_pdns_id, data)
+            if data["rrsets"]:
+                _pdns_patch(NSLORD, "/zones/" + self.domain_pdns_id, data)
 
 
         def api_do(self):
         def api_do(self):
             pass
             pass
 
 
         def __str__(self):
         def __str__(self):
-            return 'Update RRsets of %s: additions=%s, modifications=%s, deletions=%s' % \
-                   (self.domain_name, list(self._additions), list(self._modifications), list(self._deletions))
+            return (
+                "Update RRsets of %s: additions=%s, modifications=%s, deletions=%s"
+                % (
+                    self.domain_name,
+                    list(self._additions),
+                    list(self._modifications),
+                    list(self._deletions),
+                )
+            )
 
 
     def __init__(self):
     def __init__(self):
         self._domain_additions = set()
         self._domain_additions = set()
@@ -221,30 +253,44 @@ class PDNSChangeTracker:
             return f()
             return f()
 
 
     def _manage_signals(self, method):
     def _manage_signals(self, method):
-        if method not in ['connect', 'disconnect']:
+        if method not in ["connect", "disconnect"]:
             raise ValueError()
             raise ValueError()
-        getattr(post_save, method)(self._on_rr_post_save, sender=RR, dispatch_uid=self.__module__)
-        getattr(post_delete, method)(self._on_rr_post_delete, sender=RR, dispatch_uid=self.__module__)
-        getattr(post_save, method)(self._on_rr_set_post_save, sender=RRset, dispatch_uid=self.__module__)
-        getattr(post_delete, method)(self._on_rr_set_post_delete, sender=RRset, dispatch_uid=self.__module__)
-        getattr(post_save, method)(self._on_domain_post_save, sender=Domain, dispatch_uid=self.__module__)
-        getattr(post_delete, method)(self._on_domain_post_delete, sender=Domain, dispatch_uid=self.__module__)
+        getattr(post_save, method)(
+            self._on_rr_post_save, sender=RR, dispatch_uid=self.__module__
+        )
+        getattr(post_delete, method)(
+            self._on_rr_post_delete, sender=RR, dispatch_uid=self.__module__
+        )
+        getattr(post_save, method)(
+            self._on_rr_set_post_save, sender=RRset, dispatch_uid=self.__module__
+        )
+        getattr(post_delete, method)(
+            self._on_rr_set_post_delete, sender=RRset, dispatch_uid=self.__module__
+        )
+        getattr(post_save, method)(
+            self._on_domain_post_save, sender=Domain, dispatch_uid=self.__module__
+        )
+        getattr(post_delete, method)(
+            self._on_domain_post_delete, sender=Domain, dispatch_uid=self.__module__
+        )
 
 
     def __enter__(self):
     def __enter__(self):
         PDNSChangeTracker._active_change_trackers += 1
         PDNSChangeTracker._active_change_trackers += 1
-        assert PDNSChangeTracker._active_change_trackers == 1, 'Nesting %s is not supported.' % self.__class__.__name__
+        assert PDNSChangeTracker._active_change_trackers == 1, (
+            "Nesting %s is not supported." % self.__class__.__name__
+        )
         self._domain_additions = set()
         self._domain_additions = set()
         self._domain_deletions = set()
         self._domain_deletions = set()
         self._rr_set_additions = {}
         self._rr_set_additions = {}
         self._rr_set_modifications = {}
         self._rr_set_modifications = {}
         self._rr_set_deletions = {}
         self._rr_set_deletions = {}
-        self._manage_signals('connect')
+        self._manage_signals("connect")
         self.transaction = atomic()
         self.transaction = atomic()
         self.transaction.__enter__()
         self.transaction.__enter__()
 
 
     def __exit__(self, exc_type, exc_val, exc_tb):
     def __exit__(self, exc_type, exc_val, exc_tb):
         PDNSChangeTracker._active_change_trackers -= 1
         PDNSChangeTracker._active_change_trackers -= 1
-        self._manage_signals('disconnect')
+        self._manage_signals("disconnect")
 
 
         if exc_type:
         if exc_type:
             # An exception occurred inside our context, exit db transaction and dismiss pdns changes
             # An exception occurred inside our context, exit db transaction and dismiss pdns changes
@@ -262,13 +308,15 @@ class PDNSChangeTracker:
                     axfr_required.add(change.domain_name)
                     axfr_required.add(change.domain_name)
             except Exception as e:
             except Exception as e:
                 self.transaction.__exit__(type(e), e, e.__traceback__)
                 self.transaction.__exit__(type(e), e, e.__traceback__)
-                exc = ValueError(f'For changes {list(map(str, changes))}, {type(e)} occurred during {change}: {str(e)}')
+                exc = ValueError(
+                    f"For changes {list(map(str, changes))}, {type(e)} occurred during {change}: {str(e)}"
+                )
                 raise exc from e
                 raise exc from e
 
 
         self.transaction.__exit__(None, None, None)
         self.transaction.__exit__(None, None, None)
 
 
         for name in axfr_required:
         for name in axfr_required:
-            _pdns_put(NSMASTER, '/zones/%s/axfr-retrieve' % pdns_id(name))
+            _pdns_put(NSMASTER, "/zones/%s/axfr-retrieve" % pdns_id(name))
         Domain.objects.filter(name__in=axfr_required).update(published=timezone.now())
         Domain.objects.filter(name__in=axfr_required).update(published=timezone.now())
 
 
     def _compute_changes(self):
     def _compute_changes(self):
@@ -307,16 +355,21 @@ class PDNSChangeTracker:
             # Conditions (b) and (c) are already covered in the modifications and deletions list,
             # Conditions (b) and (c) are already covered in the modifications and deletions list,
             # we filter the additions list to remove newly-added, but empty RR sets
             # we filter the additions list to remove newly-added, but empty RR sets
             additions -= {
             additions -= {
-                (type_, subname) for (type_, subname) in additions
+                (type_, subname)
+                for (type_, subname) in additions
                 if not RR.objects.filter(
                 if not RR.objects.filter(
                     rrset__domain__name=domain_name,
                     rrset__domain__name=domain_name,
                     rrset__type=type_,
                     rrset__type=type_,
-                    rrset__subname=subname).exists()
+                    rrset__subname=subname,
+                ).exists()
             }
             }
 
 
             if additions | modifications | deletions:
             if additions | modifications | deletions:
-                changes.append(PDNSChangeTracker.CreateUpdateDeleteRRSets(
-                    domain_name, additions, modifications, deletions))
+                changes.append(
+                    PDNSChangeTracker.CreateUpdateDeleteRRSets(
+                        domain_name, additions, modifications, deletions
+                    )
+                )
 
 
         return changes
         return changes
 
 
@@ -349,7 +402,9 @@ class PDNSChangeTracker:
             modifications.add(item)
             modifications.add(item)
             assert item not in deletions
             assert item not in deletions
         else:
         else:
-            raise ValueError('An RR set cannot be created and deleted at the same time.')
+            raise ValueError(
+                "An RR set cannot be created and deleted at the same time."
+            )
 
 
     def _domain_updated(self, domain: Domain, created=False, deleted=False):
     def _domain_updated(self, domain: Domain, created=False, deleted=False):
         if not created and not deleted:
         if not created and not deleted:
@@ -361,7 +416,9 @@ class PDNSChangeTracker:
         deletions = self._domain_deletions
         deletions = self._domain_deletions
 
 
         if created and deleted:
         if created and deleted:
-            raise ValueError('A domain set cannot be created and deleted at the same time.')
+            raise ValueError(
+                "A domain set cannot be created and deleted at the same time."
+            )
 
 
         if created:
         if created:
             if name in deletions:
             if name in deletions:
@@ -375,7 +432,9 @@ class PDNSChangeTracker:
                 deletions.add(name)
                 deletions.add(name)
 
 
     # noinspection PyUnusedLocal
     # noinspection PyUnusedLocal
-    def _on_rr_post_save(self, signal, sender, instance: RR, created, update_fields, raw, using, **kwargs):
+    def _on_rr_post_save(
+        self, signal, sender, instance: RR, created, update_fields, raw, using, **kwargs
+    ):
         self._rr_set_updated(instance.rrset)
         self._rr_set_updated(instance.rrset)
 
 
     # noinspection PyUnusedLocal
     # noinspection PyUnusedLocal
@@ -386,7 +445,17 @@ class PDNSChangeTracker:
             pass
             pass
 
 
     # noinspection PyUnusedLocal
     # noinspection PyUnusedLocal
-    def _on_rr_set_post_save(self, signal, sender, instance: RRset, created, update_fields, raw, using, **kwargs):
+    def _on_rr_set_post_save(
+        self,
+        signal,
+        sender,
+        instance: RRset,
+        created,
+        update_fields,
+        raw,
+        using,
+        **kwargs,
+    ):
         self._rr_set_updated(instance, created=created)
         self._rr_set_updated(instance, created=created)
 
 
     # noinspection PyUnusedLocal
     # noinspection PyUnusedLocal
@@ -394,7 +463,17 @@ class PDNSChangeTracker:
         self._rr_set_updated(instance, deleted=True)
         self._rr_set_updated(instance, deleted=True)
 
 
     # noinspection PyUnusedLocal
     # noinspection PyUnusedLocal
-    def _on_domain_post_save(self, signal, sender, instance: Domain, created, update_fields, raw, using, **kwargs):
+    def _on_domain_post_save(
+        self,
+        signal,
+        sender,
+        instance: Domain,
+        created,
+        update_fields,
+        raw,
+        using,
+        **kwargs,
+    ):
         self._domain_updated(instance, created=created)
         self._domain_updated(instance, created=created)
 
 
     # noinspection PyUnusedLocal
     # noinspection PyUnusedLocal
@@ -402,10 +481,13 @@ class PDNSChangeTracker:
         self._domain_updated(instance, deleted=True)
         self._domain_updated(instance, deleted=True)
 
 
     def __str__(self):
     def __str__(self):
-        all_rr_sets = self._rr_set_additions.keys() | self._rr_set_modifications.keys() | self._rr_set_deletions.keys()
+        all_rr_sets = (
+            self._rr_set_additions.keys()
+            | self._rr_set_modifications.keys()
+            | self._rr_set_deletions.keys()
+        )
         all_domains = self._domain_additions | self._domain_deletions
         all_domains = self._domain_additions | self._domain_deletions
-        return '<%s: %i added or deleted domains; %i added, modified or deleted RR sets>' % (
-            self.__class__.__name__,
-            len(all_domains),
-            len(all_rr_sets)
+        return (
+            "<%s: %i added or deleted domains; %i added, modified or deleted RR sets>"
+            % (self.__class__.__name__, len(all_domains), len(all_rr_sets))
         )
         )

+ 18 - 8
api/desecapi/permissions.py

@@ -44,6 +44,7 @@ class TokenDomainPolicyBasePermission(permissions.BasePermission):
     """
     """
     Base permission to check whether a token authorizes specific actions on a domain.
     Base permission to check whether a token authorizes specific actions on a domain.
     """
     """
+
     perm_field = None
     perm_field = None
 
 
     def _has_object_permission(self, request, view, obj):
     def _has_object_permission(self, request, view, obj):
@@ -70,14 +71,16 @@ class TokenHasDomainDynDNSPermission(TokenHasDomainBasePermission):
     """
     """
     Custom permission to check whether a token authorizes using the dynDNS interface for the view domain.
     Custom permission to check whether a token authorizes using the dynDNS interface for the view domain.
     """
     """
-    perm_field = 'perm_dyndns'
+
+    perm_field = "perm_dyndns"
 
 
 
 
 class TokenHasDomainRRsetsPermission(TokenHasDomainBasePermission):
 class TokenHasDomainRRsetsPermission(TokenHasDomainBasePermission):
     """
     """
     Custom permission to check whether a token authorizes accessing RRsets for the view domain.
     Custom permission to check whether a token authorizes accessing RRsets for the view domain.
     """
     """
-    perm_field = 'perm_rrsets'
+
+    perm_field = "perm_rrsets"
 
 
 
 
 class AuthTokenCorrespondsToViewToken(permissions.BasePermission):
 class AuthTokenCorrespondsToViewToken(permissions.BasePermission):
@@ -86,18 +89,19 @@ class AuthTokenCorrespondsToViewToken(permissions.BasePermission):
     """
     """
 
 
     def has_permission(self, request, view):
     def has_permission(self, request, view):
-        return view.kwargs['token_id'] == request.auth.pk
+        return view.kwargs["token_id"] == request.auth.pk
 
 
 
 
 class IsVPNClient(permissions.BasePermission):
 class IsVPNClient(permissions.BasePermission):
     """
     """
     Permission that requires that the user is accessing using an IP from the VPN net.
     Permission that requires that the user is accessing using an IP from the VPN net.
     """
     """
-    message = 'Inadmissible client IP.'
+
+    message = "Inadmissible client IP."
 
 
     def has_permission(self, request, view):
     def has_permission(self, request, view):
-        ip = IPv4Address(request.META.get('REMOTE_ADDR'))
-        return ip in IPv4Network('10.8.0.0/24')
+        ip = IPv4Address(request.META.get("REMOTE_ADDR"))
+        return ip in IPv4Network("10.8.0.0/24")
 
 
 
 
 class HasManageTokensPermission(permissions.BasePermission):
 class HasManageTokensPermission(permissions.BasePermission):
@@ -113,7 +117,13 @@ class WithinDomainLimit(permissions.BasePermission):
     """
     """
     Permission that requires that the user still has domain limit quota available.
     Permission that requires that the user still has domain limit quota available.
     """
     """
-    message = 'Domain limit exceeded. Please contact support to create additional domains.'
+
+    message = (
+        "Domain limit exceeded. Please contact support to create additional domains."
+    )
 
 
     def has_permission(self, request, view):
     def has_permission(self, request, view):
-        return request.user.limit_domains is None or request.user.domains.count() < request.user.limit_domains
+        return (
+            request.user.limit_domains is None
+            or request.user.domains.count() < request.user.limit_domains
+        )

+ 8 - 6
api/desecapi/renderers.py

@@ -6,19 +6,21 @@ from rest_framework import renderers
 
 
 class PlainTextRenderer(renderers.BaseRenderer):
 class PlainTextRenderer(renderers.BaseRenderer):
     # Disregard Accept header
     # Disregard Accept header
-    media_type = '*/*'
-    format = 'txt'
+    media_type = "*/*"
+    format = "txt"
 
 
     def render(self, data, media_type=None, renderer_context=None):
     def render(self, data, media_type=None, renderer_context=None):
         renderer_context = renderer_context or {}
         renderer_context = renderer_context or {}
-        response = renderer_context.get('response')
+        response = renderer_context.get("response")
 
 
         if response and response.exception:
         if response and response.exception:
-            response['Content-Type'] = 'text/plain'
+            response["Content-Type"] = "text/plain"
             try:
             try:
-                return data['detail']
+                return data["detail"]
             except:
             except:
-                data = json.loads(json.dumps(data))  # stringify exception objects in potentially nested data structure
+                data = json.loads(
+                    json.dumps(data)
+                )  # stringify exception objects in potentially nested data structure
                 return yaml.safe_dump(data, default_flow_style=False)
                 return yaml.safe_dump(data, default_flow_style=False)
 
 
         return data
         return data

+ 87 - 51
api/desecapi/serializers/authenticated_actions.py

@@ -21,7 +21,7 @@ class CustomFieldNameUniqueValidator(UniqueValidator):
     database query field; only how the lookup must match is allowed to be changed.)
     database query field; only how the lookup must match is allowed to be changed.)
     """
     """
 
 
-    def __init__(self, queryset, message=None, lookup='exact', lookup_field=None):
+    def __init__(self, queryset, message=None, lookup="exact", lookup_field=None):
         self.lookup_field = lookup_field
         self.lookup_field = lookup_field
         super().__init__(queryset, message, lookup)
         super().__init__(queryset, message, lookup)
 
 
@@ -29,7 +29,9 @@ class CustomFieldNameUniqueValidator(UniqueValidator):
         """
         """
         Filter the queryset to all instances matching the given value on the specified lookup field.
         Filter the queryset to all instances matching the given value on the specified lookup field.
         """
         """
-        filter_kwargs = {'%s__%s' % (self.lookup_field or field_name, self.lookup): value}
+        filter_kwargs = {
+            "%s__%s" % (self.lookup_field or field_name, self.lookup): value
+        }
         return qs_filter(queryset, **filter_kwargs)
         return qs_filter(queryset, **filter_kwargs)
 
 
 
 
@@ -37,26 +39,34 @@ class AuthenticatedActionSerializer(serializers.ModelSerializer):
     state = serializers.CharField()  # serializer read-write, but model read-only field
     state = serializers.CharField()  # serializer read-write, but model read-only field
     validity_period = settings.VALIDITY_PERIOD_VERIFICATION_SIGNATURE
     validity_period = settings.VALIDITY_PERIOD_VERIFICATION_SIGNATURE
 
 
-    _crypto_context = 'desecapi.serializers.AuthenticatedActionSerializer'
+    _crypto_context = "desecapi.serializers.AuthenticatedActionSerializer"
     timestamp = None  # is set to the code's timestamp during validation
     timestamp = None  # is set to the code's timestamp during validation
 
 
     class Meta:
     class Meta:
         model = models.AuthenticatedAction
         model = models.AuthenticatedAction
-        fields = ('state',)
+        fields = ("state",)
 
 
     @classmethod
     @classmethod
     def _pack_code(cls, data):
     def _pack_code(cls, data):
         payload = json.dumps(data).encode()
         payload = json.dumps(data).encode()
         code = crypto.encrypt(payload, context=cls._crypto_context).decode()
         code = crypto.encrypt(payload, context=cls._crypto_context).decode()
-        return code.rstrip('=')
+        return code.rstrip("=")
 
 
     @classmethod
     @classmethod
     def _unpack_code(cls, code, *, ttl):
     def _unpack_code(cls, code, *, ttl):
-        code += -len(code) % 4 * '='
+        code += -len(code) % 4 * "="
         try:
         try:
-            timestamp, payload = crypto.decrypt(code.encode(), context=cls._crypto_context, ttl=ttl)
+            timestamp, payload = crypto.decrypt(
+                code.encode(), context=cls._crypto_context, ttl=ttl
+            )
             return timestamp, json.loads(payload.decode())
             return timestamp, json.loads(payload.decode())
-        except (TypeError, UnicodeDecodeError, UnicodeEncodeError, json.JSONDecodeError, binascii.Error):
+        except (
+            TypeError,
+            UnicodeDecodeError,
+            UnicodeEncodeError,
+            json.JSONDecodeError,
+            binascii.Error,
+        ):
             raise ValueError
             raise ValueError
 
 
     def to_representation(self, instance: models.AuthenticatedAction):
     def to_representation(self, instance: models.AuthenticatedAction):
@@ -64,12 +74,12 @@ class AuthenticatedActionSerializer(serializers.ModelSerializer):
         data = super().to_representation(instance)
         data = super().to_representation(instance)
 
 
         # encode into single string
         # encode into single string
-        return {'code': self._pack_code(data)}
+        return {"code": self._pack_code(data)}
 
 
     def to_internal_value(self, data):
     def to_internal_value(self, data):
         # Allow injecting validity period from context.  This is used, for example, for authentication, where the code's
         # Allow injecting validity period from context.  This is used, for example, for authentication, where the code's
         # integrity and timestamp is checked by AuthenticatedBasicUserActionSerializer with validity injected as needed.
         # integrity and timestamp is checked by AuthenticatedBasicUserActionSerializer with validity injected as needed.
-        validity_period = self.context.get('validity_period', self.validity_period)
+        validity_period = self.context.get("validity_period", self.validity_period)
         # calculate code TTL
         # calculate code TTL
         try:
         try:
             ttl = validity_period.total_seconds()
             ttl = validity_period.total_seconds()
@@ -78,14 +88,16 @@ class AuthenticatedActionSerializer(serializers.ModelSerializer):
 
 
         # decode from single string
         # decode from single string
         try:
         try:
-            self.timestamp, unpacked_data = self._unpack_code(self.context['code'], ttl=ttl)
+            self.timestamp, unpacked_data = self._unpack_code(
+                self.context["code"], ttl=ttl
+            )
         except KeyError:
         except KeyError:
-            raise serializers.ValidationError({'code': ['This field is required.']})
+            raise serializers.ValidationError({"code": ["This field is required."]})
         except ValueError:
         except ValueError:
             if ttl is None:
             if ttl is None:
-                msg = 'This code is invalid.'
+                msg = "This code is invalid."
             else:
             else:
-                msg = f'This code is invalid, possibly because it expired (validity: {validity_period}).'
+                msg = f"This code is invalid, possibly because it expired (validity: {validity_period})."
             raise serializers.ValidationError({api_settings.NON_FIELD_ERRORS_KEY: msg})
             raise serializers.ValidationError({api_settings.NON_FIELD_ERRORS_KEY: msg})
 
 
         # add extra fields added by the user, but give precedence to fields unpacked from the code
         # add extra fields added by the user, but give precedence to fields unpacked from the code
@@ -102,24 +114,26 @@ class AuthenticatedActionSerializer(serializers.ModelSerializer):
         raise ValueError
         raise ValueError
 
 
 
 
-class AuthenticatedBasicUserActionMixin():
+class AuthenticatedBasicUserActionMixin:
     def save(self, **kwargs):
     def save(self, **kwargs):
-        context = {**self.context, 'action_serializer': self}
+        context = {**self.context, "action_serializer": self}
         return self.action_user.send_email(self.reason, context=context, **kwargs)
         return self.action_user.send_email(self.reason, context=context, **kwargs)
 
 
 
 
-class AuthenticatedBasicUserActionSerializer(AuthenticatedBasicUserActionMixin, AuthenticatedActionSerializer):
+class AuthenticatedBasicUserActionSerializer(
+    AuthenticatedBasicUserActionMixin, AuthenticatedActionSerializer
+):
     user = serializers.PrimaryKeyRelatedField(
     user = serializers.PrimaryKeyRelatedField(
         queryset=models.User.objects.all(),
         queryset=models.User.objects.all(),
-        error_messages={'does_not_exist': 'This user does not exist.'},
-        pk_field=serializers.UUIDField()
+        error_messages={"does_not_exist": "This user does not exist."},
+        pk_field=serializers.UUIDField(),
     )
     )
 
 
     reason = None
     reason = None
 
 
     class Meta:
     class Meta:
         model = models.AuthenticatedBasicUserAction
         model = models.AuthenticatedBasicUserAction
-        fields = AuthenticatedActionSerializer.Meta.fields + ('user',)
+        fields = AuthenticatedActionSerializer.Meta.fields + ("user",)
 
 
     @property
     @property
     def action_user(self):
     def action_user(self):
@@ -131,8 +145,9 @@ class AuthenticatedBasicUserActionSerializer(AuthenticatedBasicUserActionMixin,
         return cls(action).save()
         return cls(action).save()
 
 
 
 
-class AuthenticatedBasicUserActionListSerializer(AuthenticatedBasicUserActionMixin, serializers.ListSerializer):
-
+class AuthenticatedBasicUserActionListSerializer(
+    AuthenticatedBasicUserActionMixin, serializers.ListSerializer
+):
     @property
     @property
     def reason(self):
     def reason(self):
         return self.child.reason
         return self.child.reason
@@ -141,100 +156,121 @@ class AuthenticatedBasicUserActionListSerializer(AuthenticatedBasicUserActionMix
     def action_user(self):
     def action_user(self):
         user = self.instance[0].user
         user = self.instance[0].user
         if any(instance.user != user for instance in self.instance):
         if any(instance.user != user for instance in self.instance):
-            raise ValueError('Actions must belong to the same user.')
+            raise ValueError("Actions must belong to the same user.")
         return user
         return user
 
 
 
 
-class AuthenticatedChangeOutreachPreferenceUserActionSerializer(AuthenticatedBasicUserActionSerializer):
-    reason = 'change-outreach-preference'
+class AuthenticatedChangeOutreachPreferenceUserActionSerializer(
+    AuthenticatedBasicUserActionSerializer
+):
+    reason = "change-outreach-preference"
     validity_period = None
     validity_period = None
 
 
     class Meta:
     class Meta:
         model = models.AuthenticatedChangeOutreachPreferenceUserAction
         model = models.AuthenticatedChangeOutreachPreferenceUserAction
-        fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ('outreach_preference',)
+        fields = AuthenticatedBasicUserActionSerializer.Meta.fields + (
+            "outreach_preference",
+        )
 
 
 
 
 class AuthenticatedActivateUserActionSerializer(AuthenticatedBasicUserActionSerializer):
 class AuthenticatedActivateUserActionSerializer(AuthenticatedBasicUserActionSerializer):
     captcha = CaptchaSolutionSerializer(required=False)
     captcha = CaptchaSolutionSerializer(required=False)
 
 
-    reason = 'activate-account'
+    reason = "activate-account"
 
 
     class Meta(AuthenticatedBasicUserActionSerializer.Meta):
     class Meta(AuthenticatedBasicUserActionSerializer.Meta):
         model = models.AuthenticatedActivateUserAction
         model = models.AuthenticatedActivateUserAction
-        fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ('captcha', 'domain',)
-        extra_kwargs = {
-            'domain': {'default': None, 'allow_null': True}
-        }
+        fields = AuthenticatedBasicUserActionSerializer.Meta.fields + (
+            "captcha",
+            "domain",
+        )
+        extra_kwargs = {"domain": {"default": None, "allow_null": True}}
 
 
     def validate(self, attrs):
     def validate(self, attrs):
         try:
         try:
-            attrs.pop('captcha')  # remove captcha from internal value to avoid passing to Meta.model(**kwargs)
+            attrs.pop(
+                "captcha"
+            )  # remove captcha from internal value to avoid passing to Meta.model(**kwargs)
         except KeyError:
         except KeyError:
-            if attrs['user'].needs_captcha:
-                raise serializers.ValidationError({'captcha': fields.Field.default_error_messages['required']})
+            if attrs["user"].needs_captcha:
+                raise serializers.ValidationError(
+                    {"captcha": fields.Field.default_error_messages["required"]}
+                )
         return attrs
         return attrs
 
 
 
 
-class AuthenticatedChangeEmailUserActionSerializer(AuthenticatedBasicUserActionSerializer):
+class AuthenticatedChangeEmailUserActionSerializer(
+    AuthenticatedBasicUserActionSerializer
+):
     new_email = serializers.EmailField(
     new_email = serializers.EmailField(
         validators=[
         validators=[
             CustomFieldNameUniqueValidator(
             CustomFieldNameUniqueValidator(
                 queryset=models.User.objects.all(),
                 queryset=models.User.objects.all(),
-                lookup_field='email',
-                message='You already have another account with this email address.',
+                lookup_field="email",
+                message="You already have another account with this email address.",
             )
             )
         ],
         ],
         required=True,
         required=True,
     )
     )
 
 
-    reason = 'change-email'
+    reason = "change-email"
 
 
     class Meta(AuthenticatedBasicUserActionSerializer.Meta):
     class Meta(AuthenticatedBasicUserActionSerializer.Meta):
         model = models.AuthenticatedChangeEmailUserAction
         model = models.AuthenticatedChangeEmailUserAction
-        fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ('new_email',)
+        fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ("new_email",)
 
 
     def save(self):
     def save(self):
         return super().save(recipient=self.instance.new_email)
         return super().save(recipient=self.instance.new_email)
 
 
 
 
-class AuthenticatedConfirmAccountUserActionSerializer(AuthenticatedBasicUserActionSerializer):
-    reason = 'confirm-account'
+class AuthenticatedConfirmAccountUserActionSerializer(
+    AuthenticatedBasicUserActionSerializer
+):
+    reason = "confirm-account"
     validity_period = timedelta(days=14)
     validity_period = timedelta(days=14)
 
 
     class Meta(AuthenticatedBasicUserActionSerializer.Meta):
     class Meta(AuthenticatedBasicUserActionSerializer.Meta):
-        model = models.AuthenticatedNoopUserAction  # confirmation happens during authentication, so nothing left to do
+        model = (
+            models.AuthenticatedNoopUserAction
+        )  # confirmation happens during authentication, so nothing left to do
 
 
 
 
-class AuthenticatedResetPasswordUserActionSerializer(AuthenticatedBasicUserActionSerializer):
+class AuthenticatedResetPasswordUserActionSerializer(
+    AuthenticatedBasicUserActionSerializer
+):
     new_password = serializers.CharField(write_only=True)
     new_password = serializers.CharField(write_only=True)
 
 
-    reason = 'reset-password'
+    reason = "reset-password"
 
 
     class Meta(AuthenticatedBasicUserActionSerializer.Meta):
     class Meta(AuthenticatedBasicUserActionSerializer.Meta):
         model = models.AuthenticatedResetPasswordUserAction
         model = models.AuthenticatedResetPasswordUserAction
-        fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ('new_password',)
+        fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ("new_password",)
 
 
 
 
 class AuthenticatedDeleteUserActionSerializer(AuthenticatedBasicUserActionSerializer):
 class AuthenticatedDeleteUserActionSerializer(AuthenticatedBasicUserActionSerializer):
-    reason = 'delete-account'
+    reason = "delete-account"
 
 
     class Meta(AuthenticatedBasicUserActionSerializer.Meta):
     class Meta(AuthenticatedBasicUserActionSerializer.Meta):
         model = models.AuthenticatedDeleteUserAction
         model = models.AuthenticatedDeleteUserAction
 
 
 
 
-class AuthenticatedDomainBasicUserActionSerializer(AuthenticatedBasicUserActionSerializer):
+class AuthenticatedDomainBasicUserActionSerializer(
+    AuthenticatedBasicUserActionSerializer
+):
     domain = serializers.PrimaryKeyRelatedField(
     domain = serializers.PrimaryKeyRelatedField(
         queryset=models.Domain.objects.all(),
         queryset=models.Domain.objects.all(),
-        error_messages={'does_not_exist': 'This domain does not exist.'},
+        error_messages={"does_not_exist": "This domain does not exist."},
     )
     )
 
 
     class Meta:
     class Meta:
         model = models.AuthenticatedDomainBasicUserAction
         model = models.AuthenticatedDomainBasicUserAction
-        fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ('domain',)
+        fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ("domain",)
 
 
 
 
-class AuthenticatedRenewDomainBasicUserActionSerializer(AuthenticatedDomainBasicUserActionSerializer):
-    reason = 'renew-domain'
+class AuthenticatedRenewDomainBasicUserActionSerializer(
+    AuthenticatedDomainBasicUserActionSerializer
+):
+    reason = "renew-domain"
     validity_period = None
     validity_period = None
 
 
     class Meta(AuthenticatedDomainBasicUserActionSerializer.Meta):
     class Meta(AuthenticatedDomainBasicUserActionSerializer.Meta):

+ 12 - 6
api/desecapi/serializers/captcha.py

@@ -13,7 +13,11 @@ class CaptchaSerializer(serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = Captcha
         model = Captcha
-        fields = ('id', 'challenge', 'kind') if not settings.DEBUG else ('id', 'challenge', 'kind', 'content')
+        fields = (
+            ("id", "challenge", "kind")
+            if not settings.DEBUG
+            else ("id", "challenge", "kind", "content")
+        )
 
 
     def get_challenge(self, obj: Captcha):
     def get_challenge(self, obj: Captcha):
         # TODO Does this need to be stored in the object instance, in case this method gets called twice?
         # TODO Does this need to be stored in the object instance, in case this method gets called twice?
@@ -22,20 +26,22 @@ class CaptchaSerializer(serializers.ModelSerializer):
         elif obj.kind == Captcha.Kind.AUDIO:
         elif obj.kind == Captcha.Kind.AUDIO:
             challenge = AudioCaptcha().generate(obj.content)
             challenge = AudioCaptcha().generate(obj.content)
         else:
         else:
-            raise ValueError(f'Unknown captcha type {obj.kind}')
+            raise ValueError(f"Unknown captcha type {obj.kind}")
         return b64encode(challenge)
         return b64encode(challenge)
 
 
 
 
 class CaptchaSolutionSerializer(serializers.Serializer):
 class CaptchaSolutionSerializer(serializers.Serializer):
     id = serializers.PrimaryKeyRelatedField(
     id = serializers.PrimaryKeyRelatedField(
         queryset=Captcha.objects.all(),
         queryset=Captcha.objects.all(),
-        error_messages={'does_not_exist': 'CAPTCHA does not exist.'}
+        error_messages={"does_not_exist": "CAPTCHA does not exist."},
     )
     )
     solution = serializers.CharField(write_only=True, required=True)
     solution = serializers.CharField(write_only=True, required=True)
 
 
     def validate(self, attrs):
     def validate(self, attrs):
-        captcha = attrs['id']  # Note that this already is the Captcha object
-        if not captcha.verify(attrs['solution']):
-            raise serializers.ValidationError('CAPTCHA could not be validated. Please obtain a new one and try again.')
+        captcha = attrs["id"]  # Note that this already is the Captcha object
+        if not captcha.verify(attrs["solution"]):
+            raise serializers.ValidationError(
+                "CAPTCHA could not be validated. Please obtain a new one and try again."
+            )
 
 
         return attrs
         return attrs

+ 83 - 37
api/desecapi/serializers/domains.py

@@ -12,16 +12,27 @@ from .records import RRsetSerializer
 class DomainSerializer(serializers.ModelSerializer):
 class DomainSerializer(serializers.ModelSerializer):
     default_error_messages = {
     default_error_messages = {
         **serializers.Serializer.default_error_messages,
         **serializers.Serializer.default_error_messages,
-        'name_unavailable': 'This domain name conflicts with an existing zone, or is disallowed by policy.',
+        "name_unavailable": "This domain name conflicts with an existing zone, or is disallowed by policy.",
     }
     }
     zonefile = serializers.CharField(write_only=True, required=False, allow_blank=True)
     zonefile = serializers.CharField(write_only=True, required=False, allow_blank=True)
 
 
     class Meta:
     class Meta:
         model = Domain
         model = Domain
-        fields = ('created', 'published', 'name', 'keys', 'minimum_ttl', 'touched', 'zonefile')
-        read_only_fields = ('published', 'minimum_ttl',)
+        fields = (
+            "created",
+            "published",
+            "name",
+            "keys",
+            "minimum_ttl",
+            "touched",
+            "zonefile",
+        )
+        read_only_fields = (
+            "published",
+            "minimum_ttl",
+        )
         extra_kwargs = {
         extra_kwargs = {
-            'name': {'trim_whitespace': False},
+            "name": {"trim_whitespace": False},
         }
         }
 
 
     def __init__(self, *args, include_keys=False, **kwargs):
     def __init__(self, *args, include_keys=False, **kwargs):
@@ -32,13 +43,15 @@ class DomainSerializer(serializers.ModelSerializer):
     def get_fields(self):
     def get_fields(self):
         fields = super().get_fields()
         fields = super().get_fields()
         if not self.include_keys:
         if not self.include_keys:
-            fields.pop('keys')
-        fields['name'].validators.append(ReadOnlyOnUpdateValidator())
+            fields.pop("keys")
+        fields["name"].validators.append(ReadOnlyOnUpdateValidator())
         return fields
         return fields
 
 
     def validate_name(self, value):
     def validate_name(self, value):
-        if not Domain(name=value, owner=self.context['request'].user).is_registrable():
-            raise serializers.ValidationError(self.default_error_messages['name_unavailable'], code='name_unavailable')
+        if not Domain(name=value, owner=self.context["request"].user).is_registrable():
+            raise serializers.ValidationError(
+                self.default_error_messages["name_unavailable"], code="name_unavailable"
+            )
         return value
         return value
 
 
     def parse_zonefile(self, domain_name: str, zonefile: str):
     def parse_zonefile(self, domain_name: str, zonefile: str):
@@ -52,54 +65,83 @@ class DomainSerializer(serializers.ModelSerializer):
             )
             )
         except dns.zonefile.CNAMEAndOtherData:
         except dns.zonefile.CNAMEAndOtherData:
             raise serializers.ValidationError(
             raise serializers.ValidationError(
-                {'zonefile': ['No other records with the same name are allowed alongside a CNAME record.']})
+                {
+                    "zonefile": [
+                        "No other records with the same name are allowed alongside a CNAME record."
+                    ]
+                }
+            )
         except ValueError as e:
         except ValueError as e:
-            if 'has non-origin SOA' in str(e):
+            if "has non-origin SOA" in str(e):
                 raise serializers.ValidationError(
                 raise serializers.ValidationError(
-                    {'zonefile': [f'Zonefile includes an SOA record for a name different from {domain_name}.']})
+                    {
+                        "zonefile": [
+                            f"Zonefile includes an SOA record for a name different from {domain_name}."
+                        ]
+                    }
+                )
             raise e
             raise e
         except dns.exception.SyntaxError as e:
         except dns.exception.SyntaxError as e:
             try:
             try:
-                line = str(e).split(':')[1]
-                raise serializers.ValidationError({'zonefile': [f'Zonefile contains syntax error in line {line}.']})
+                line = str(e).split(":")[1]
+                raise serializers.ValidationError(
+                    {"zonefile": [f"Zonefile contains syntax error in line {line}."]}
+                )
             except IndexError:
             except IndexError:
-                raise serializers.ValidationError({'zonefile': [f'Could not parse zonefile: {str(e)}']})
+                raise serializers.ValidationError(
+                    {"zonefile": [f"Could not parse zonefile: {str(e)}"]}
+                )
 
 
     def validate(self, attrs):
     def validate(self, attrs):
-        if attrs.get('zonefile') is not None:
-            self.parse_zonefile(attrs.get('name'), attrs.pop('zonefile'))
+        if attrs.get("zonefile") is not None:
+            self.parse_zonefile(attrs.get("name"), attrs.pop("zonefile"))
         return super().validate(attrs)
         return super().validate(attrs)
 
 
     def create(self, validated_data):
     def create(self, validated_data):
         # save domain
         # save domain
-        if 'minimum_ttl' not in validated_data and Domain(name=validated_data['name']).is_locally_registrable:
+        if (
+            "minimum_ttl" not in validated_data
+            and Domain(name=validated_data["name"]).is_locally_registrable
+        ):
             validated_data.update(minimum_ttl=60)
             validated_data.update(minimum_ttl=60)
         domain: Domain = super().create(validated_data)
         domain: Domain = super().create(validated_data)
 
 
         # save RRsets if zonefile was given
         # save RRsets if zonefile was given
-        nodes = getattr(self.import_zone, 'nodes', None)
+        nodes = getattr(self.import_zone, "nodes", None)
         if nodes:
         if nodes:
-            zone_name = dns.name.from_text(validated_data['name'])
+            zone_name = dns.name.from_text(validated_data["name"])
             min_ttl, max_ttl = domain.minimum_ttl, settings.MAXIMUM_TTL
             min_ttl, max_ttl = domain.minimum_ttl, settings.MAXIMUM_TTL
             data = [
             data = [
                 {
                 {
-                    'type': dns.rdatatype.to_text(rrset.rdtype),
-                    'ttl': max(min_ttl, min(max_ttl, rrset.ttl)),
-                    'subname': (owner_name - zone_name).to_text() if owner_name - zone_name != dns.name.empty else '',
-                    'records': [rr.to_text() for rr in rrset],
+                    "type": dns.rdatatype.to_text(rrset.rdtype),
+                    "ttl": max(min_ttl, min(max_ttl, rrset.ttl)),
+                    "subname": (owner_name - zone_name).to_text()
+                    if owner_name - zone_name != dns.name.empty
+                    else "",
+                    "records": [rr.to_text() for rr in rrset],
                 }
                 }
                 for owner_name, node in nodes.items()
                 for owner_name, node in nodes.items()
                 for rrset in node.rdatasets
                 for rrset in node.rdatasets
                 if (
                 if (
-                    dns.rdatatype.to_text(rrset.rdtype) not in (
-                        RR_SET_TYPES_AUTOMATIC |  # do not import automatically managed record types
-                        {'CDS', 'CDNSKEY', 'DNSKEY'}  # do not import these, as this would likely be unexpected
+                    dns.rdatatype.to_text(rrset.rdtype)
+                    not in (
+                        RR_SET_TYPES_AUTOMATIC
+                        | {  # do not import automatically managed record types
+                            "CDS",
+                            "CDNSKEY",
+                            "DNSKEY",
+                        }  # do not import these, as this would likely be unexpected
                     )
                     )
-                    and not (owner_name - zone_name == dns.name.empty and rrset.rdtype == dns.rdatatype.NS)  # ignore apex NS
+                    and not (
+                        owner_name - zone_name == dns.name.empty
+                        and rrset.rdtype == dns.rdatatype.NS
+                    )  # ignore apex NS
                 )
                 )
             ]
             ]
 
 
-            rrset_list_serializer = RRsetSerializer(data=data, context=dict(domain=domain), many=True)
+            rrset_list_serializer = RRsetSerializer(
+                data=data, context=dict(domain=domain), many=True
+            )
             # The following line raises if data passed validation by dnspython during zone file parsing,
             # The following line raises if data passed validation by dnspython during zone file parsing,
             # but is rejected by validation in RRsetSerializer. See also
             # but is rejected by validation in RRsetSerializer. See also
             # test_create_domain_zonefile_import_validation
             # test_create_domain_zonefile_import_validation
@@ -109,15 +151,19 @@ class DomainSerializer(serializers.ModelSerializer):
                 if isinstance(e.detail, serializers.ReturnList):
                 if isinstance(e.detail, serializers.ReturnList):
                     # match the order of error messages with the RRsets provided to the
                     # match the order of error messages with the RRsets provided to the
                     # serializer to make sense to the client
                     # serializer to make sense to the client
-                    def fqdn(idx): return (data[idx]['subname'] + "." + domain.name).lstrip('.')
-                    raise serializers.ValidationError({
-                        'zonefile': [
-                            f"{fqdn(idx)}/{data[idx]['type']}: {err}"
-                            for idx, d in enumerate(e.detail)
-                            for _, errs in d.items()
-                            for err in errs
-                        ]
-                    })
+                    def fqdn(idx):
+                        return (data[idx]["subname"] + "." + domain.name).lstrip(".")
+
+                    raise serializers.ValidationError(
+                        {
+                            "zonefile": [
+                                f"{fqdn(idx)}/{data[idx]['type']}: {err}"
+                                for idx, d in enumerate(e.detail)
+                                for _, errs in d.items()
+                                for err in errs
+                            ]
+                        }
+                    )
 
 
                 raise e
                 raise e
 
 

+ 16 - 9
api/desecapi/serializers/donation.py

@@ -6,25 +6,32 @@ from desecapi import models
 
 
 
 
 class DonationSerializer(serializers.ModelSerializer):
 class DonationSerializer(serializers.ModelSerializer):
-
     class Meta:
     class Meta:
         model = models.Donation
         model = models.Donation
-        fields = ('name', 'iban', 'bic', 'amount', 'message', 'email', 'mref', 'interval')
-        read_only_fields = ('mref',)
+        fields = (
+            "name",
+            "iban",
+            "bic",
+            "amount",
+            "message",
+            "email",
+            "mref",
+            "interval",
+        )
+        read_only_fields = ("mref",)
         extra_kwargs = {  # do not return sensitive information
         extra_kwargs = {  # do not return sensitive information
-            'iban': {'write_only': True},
-            'bic': {'write_only': True},
-            'message': {'write_only': True},
+            "iban": {"write_only": True},
+            "bic": {"write_only": True},
+            "message": {"write_only": True},
         }
         }
 
 
-
     @staticmethod
     @staticmethod
     def validate_bic(value):
     def validate_bic(value):
-        return re.sub(r'[\s]', '', value)
+        return re.sub(r"[\s]", "", value)
 
 
     @staticmethod
     @staticmethod
     def validate_iban(value):
     def validate_iban(value):
-        return re.sub(r'[\s]', '', value)
+        return re.sub(r"[\s]", "", value)
 
 
     def create(self, validated_data):
     def create(self, validated_data):
         return self.Meta.model(**validated_data)
         return self.Meta.model(**validated_data)

+ 168 - 93
api/desecapi/serializers/records.py

@@ -32,7 +32,9 @@ class ConditionalExistenceModelSerializer(serializers.ModelSerializer):
         raise NotImplementedError
         raise NotImplementedError
 
 
     def to_representation(self, instance):
     def to_representation(self, instance):
-        return None if not self.exists(instance) else super().to_representation(instance)
+        return (
+            None if not self.exists(instance) else super().to_representation(instance)
+        )
 
 
     @property
     @property
     def data(self):
     def data(self):
@@ -71,36 +73,38 @@ class NonBulkOnlyDefault:
     operations.
     operations.
     Implementation inspired by CreateOnlyDefault.
     Implementation inspired by CreateOnlyDefault.
     """
     """
+
     requires_context = True
     requires_context = True
 
 
     def __init__(self, default):
     def __init__(self, default):
         self.default = default
         self.default = default
 
 
     def __call__(self, serializer_field):
     def __call__(self, serializer_field):
-        is_many = getattr(serializer_field.root, 'many', False)
+        is_many = getattr(serializer_field.root, "many", False)
         if is_many:
         if is_many:
             raise serializers.SkipField()
             raise serializers.SkipField()
         if callable(self.default):
         if callable(self.default):
-            if getattr(self.default, 'requires_context', False):
+            if getattr(self.default, "requires_context", False):
                 return self.default(serializer_field)
                 return self.default(serializer_field)
             else:
             else:
                 return self.default()
                 return self.default()
         return self.default
         return self.default
 
 
     def __repr__(self):
     def __repr__(self):
-        return '%s(%s)' % (self.__class__.__name__, repr(self.default))
+        return "%s(%s)" % (self.__class__.__name__, repr(self.default))
 
 
 
 
 class RRSerializer(serializers.ModelSerializer):
 class RRSerializer(serializers.ModelSerializer):
-
     class Meta:
     class Meta:
         model = models.RR
         model = models.RR
-        fields = ('content',)
+        fields = ("content",)
 
 
     def to_internal_value(self, data):
     def to_internal_value(self, data):
         if not isinstance(data, str):
         if not isinstance(data, str):
-            raise serializers.ValidationError('Must be a string.', code='must-be-a-string')
-        return super().to_internal_value({'content': data})
+            raise serializers.ValidationError(
+                "Must be a string.", code="must-be-a-string"
+            )
+        return super().to_internal_value({"content": data})
 
 
     def to_representation(self, instance):
     def to_representation(self, instance):
         return instance.content
         return instance.content
@@ -110,12 +114,12 @@ class RRsetListSerializer(serializers.ListSerializer):
     default_error_messages = {
     default_error_messages = {
         **serializers.Serializer.default_error_messages,
         **serializers.Serializer.default_error_messages,
         **serializers.ListSerializer.default_error_messages,
         **serializers.ListSerializer.default_error_messages,
-        **{'not_a_list': 'Expected a list of items but got {input_type}.'},
+        **{"not_a_list": "Expected a list of items but got {input_type}."},
     }
     }
 
 
     @staticmethod
     @staticmethod
     def _key(data_item):
     def _key(data_item):
-        return data_item.get('subname'), data_item.get('type')
+        return data_item.get("subname"), data_item.get("type")
 
 
     @staticmethod
     @staticmethod
     def _types_by_position_string(conflicting_indices_by_type):
     def _types_by_position_string(conflicting_indices_by_type):
@@ -124,24 +128,33 @@ class RRsetListSerializer(serializers.ListSerializer):
             for position in conflict_positions:
             for position in conflict_positions:
                 types_by_position.setdefault(position, []).append(type_)
                 types_by_position.setdefault(position, []).append(type_)
         # Sort by position, None at the end
         # Sort by position, None at the end
-        types_by_position = dict(sorted(types_by_position.items(), key=lambda x: (x[0] is None, x)))
+        types_by_position = dict(
+            sorted(types_by_position.items(), key=lambda x: (x[0] is None, x))
+        )
         db_conflicts = types_by_position.pop(None, None)
         db_conflicts = types_by_position.pop(None, None)
-        if db_conflicts: types_by_position['database'] = db_conflicts
+        if db_conflicts:
+            types_by_position["database"] = db_conflicts
         for position, types in types_by_position.items():
         for position, types in types_by_position.items():
-            types_by_position[position] = ', '.join(sorted(types))
-        types_by_position = [f'{position} ({types})' for position, types in types_by_position.items()]
-        return ', '.join(types_by_position)
+            types_by_position[position] = ", ".join(sorted(types))
+        types_by_position = [
+            f"{position} ({types})" for position, types in types_by_position.items()
+        ]
+        return ", ".join(types_by_position)
 
 
     def to_internal_value(self, data):
     def to_internal_value(self, data):
         if not isinstance(data, list):
         if not isinstance(data, list):
-            message = self.error_messages['not_a_list'].format(input_type=type(data).__name__)
-            raise serializers.ValidationError({api_settings.NON_FIELD_ERRORS_KEY: [message]}, code='not_a_list')
+            message = self.error_messages["not_a_list"].format(
+                input_type=type(data).__name__
+            )
+            raise serializers.ValidationError(
+                {api_settings.NON_FIELD_ERRORS_KEY: [message]}, code="not_a_list"
+            )
 
 
         if not self.allow_empty and len(data) == 0:
         if not self.allow_empty and len(data) == 0:
             if self.parent and self.partial:
             if self.parent and self.partial:
                 raise serializers.SkipField()
                 raise serializers.SkipField()
             else:
             else:
-                self.fail('empty')
+                self.fail("empty")
 
 
         partial = self.partial
         partial = self.partial
 
 
@@ -156,13 +169,19 @@ class RRsetListSerializer(serializers.ListSerializer):
         for idx, item in enumerate(data):
         for idx, item in enumerate(data):
             # Validate data types before using anything from it
             # Validate data types before using anything from it
             if not isinstance(item, dict):
             if not isinstance(item, dict):
-                errors[idx].update(non_field_errors=f"Expected a dictionary, but got {type(item).__name__}.")
+                errors[idx].update(
+                    non_field_errors=f"Expected a dictionary, but got {type(item).__name__}."
+                )
                 continue
                 continue
             s, t = self._key(item)  # subname, type
             s, t = self._key(item)  # subname, type
             if not (isinstance(s, str) or s is None):
             if not (isinstance(s, str) or s is None):
-                errors[idx].update(subname=f"Expected a string, but got {type(s).__name__}.")
+                errors[idx].update(
+                    subname=f"Expected a string, but got {type(s).__name__}."
+                )
             if not (isinstance(t, str) or t is None):
             if not (isinstance(t, str) or t is None):
-                errors[idx].update(type=f"Expected a string, but got {type(t).__name__}.")
+                errors[idx].update(
+                    type=f"Expected a string, but got {type(t).__name__}."
+                )
             if errors[idx]:
             if errors[idx]:
                 continue
                 continue
 
 
@@ -170,7 +189,9 @@ class RRsetListSerializer(serializers.ListSerializer):
             # (although invalid), we make indices[s][t] a set to properly keep track. We also check and record RRsets
             # (although invalid), we make indices[s][t] a set to properly keep track. We also check and record RRsets
             # which are known in the database (once per subname), using index `None` (for checking CNAME exclusivity).
             # which are known in the database (once per subname), using index `None` (for checking CNAME exclusivity).
             if s not in indices:
             if s not in indices:
-                types = self.child.domain.rrset_set.filter(subname=s).values_list('type', flat=True)
+                types = self.child.domain.rrset_set.filter(subname=s).values_list(
+                    "type", flat=True
+                )
                 indices[s] = {type_: {None} for type_ in types}
                 indices[s] = {type_: {None} for type_ in types}
             items = indices[s].setdefault(t, set())
             items = indices[s].setdefault(t, set())
             items.add(idx)
             items.add(idx)
@@ -179,7 +200,7 @@ class RRsetListSerializer(serializers.ListSerializer):
         for idx, item in enumerate(data):
         for idx, item in enumerate(data):
             if errors[idx]:
             if errors[idx]:
                 continue
                 continue
-            if item.get('records') == []:
+            if item.get("records") == []:
                 s, t = self._key(item)
                 s, t = self._key(item)
                 collapsed_indices[s][t] -= {idx, None}
                 collapsed_indices[s][t] -= {idx, None}
 
 
@@ -193,31 +214,40 @@ class RRsetListSerializer(serializers.ListSerializer):
                 s, t = self._key(item)
                 s, t = self._key(item)
                 data_indices = indices[s][t] - {None}
                 data_indices = indices[s][t] - {None}
                 if len(data_indices) > 1:
                 if len(data_indices) > 1:
-                    raise serializers.ValidationError({
-                        'non_field_errors': [
-                            'Same subname and type as in position(s) %s, but must be unique.' %
-                            ', '.join(map(str, data_indices - {idx}))
-                        ]
-                    })
+                    raise serializers.ValidationError(
+                        {
+                            "non_field_errors": [
+                                "Same subname and type as in position(s) %s, but must be unique."
+                                % ", ".join(map(str, data_indices - {idx}))
+                            ]
+                        }
+                    )
 
 
                 # see if other rows violate CNAME exclusivity
                 # see if other rows violate CNAME exclusivity
-                if item.get('records') != []:
-                    conflicting_indices_by_type = {k: v for k, v in collapsed_indices[s].items()
-                                                   if (k == 'CNAME') != (t == 'CNAME')}
+                if item.get("records") != []:
+                    conflicting_indices_by_type = {
+                        k: v
+                        for k, v in collapsed_indices[s].items()
+                        if (k == "CNAME") != (t == "CNAME")
+                    }
                     if any(conflicting_indices_by_type.values()):
                     if any(conflicting_indices_by_type.values()):
-                        types_by_position = self._types_by_position_string(conflicting_indices_by_type)
-                        raise serializers.ValidationError({
-                            'non_field_errors': [
-                                f'RRset with conflicting type present: {types_by_position}.'
-                                ' (No other RRsets are allowed alongside CNAME.)'
-                            ]
-                        })
+                        types_by_position = self._types_by_position_string(
+                            conflicting_indices_by_type
+                        )
+                        raise serializers.ValidationError(
+                            {
+                                "non_field_errors": [
+                                    f"RRset with conflicting type present: {types_by_position}."
+                                    " (No other RRsets are allowed alongside CNAME.)"
+                                ]
+                            }
+                        )
 
 
                 # determine if this is a partial update (i.e. PATCH):
                 # determine if this is a partial update (i.e. PATCH):
                 # we allow partial update if a partial update method (i.e. PATCH) is used, as indicated by self.partial,
                 # we allow partial update if a partial update method (i.e. PATCH) is used, as indicated by self.partial,
                 # and if this is not actually a create request because it is unknown and nonempty
                 # and if this is not actually a create request because it is unknown and nonempty
                 unknown = self._key(item) not in known_instances.keys()
                 unknown = self._key(item) not in known_instances.keys()
-                nonempty = item.get('records', None) != []
+                nonempty = item.get("records", None) != []
                 self.partial = partial and not (unknown and nonempty)
                 self.partial = partial and not (unknown and nonempty)
                 self.child.instance = known_instances.get(self._key(item), None)
                 self.child.instance = known_instances.get(self._key(item), None)
 
 
@@ -265,21 +295,28 @@ class RRsetListSerializer(serializers.ListSerializer):
         :param validated_data: List of RRset data objects, i.e. dictionaries.
         :param validated_data: List of RRset data objects, i.e. dictionaries.
         :return: List of RRset objects (Django.Model subclass) that have been created or updated.
         :return: List of RRset objects (Django.Model subclass) that have been created or updated.
         """
         """
+
         def is_empty(data_item):
         def is_empty(data_item):
-            return data_item.get('records', None) == []
+            return data_item.get("records", None) == []
 
 
-        query = Q(pk__in=[])  # start out with an always empty query, see https://stackoverflow.com/q/35893867/6867099
+        query = Q(
+            pk__in=[]
+        )  # start out with an always empty query, see https://stackoverflow.com/q/35893867/6867099
         for item in validated_data:
         for item in validated_data:
-            query |= Q(type=item['type'], subname=item['subname'])  # validation has ensured these fields exist
+            query |= Q(
+                type=item["type"], subname=item["subname"]
+            )  # validation has ensured these fields exist
         instance = instance.filter(query)
         instance = instance.filter(query)
 
 
         instance_index = {(rrset.subname, rrset.type): rrset for rrset in instance}
         instance_index = {(rrset.subname, rrset.type): rrset for rrset in instance}
         data_index = {self._key(data): data for data in validated_data}
         data_index = {self._key(data): data for data in validated_data}
 
 
         if data_index.keys() | instance_index.keys() != data_index.keys():
         if data_index.keys() | instance_index.keys() != data_index.keys():
-            raise ValueError('Given set of known RRsets (`instance`) is not a subset of RRsets referred to in'
-                             ' `validated_data`. While this would produce a correct result, this is illegal due to its'
-                             ' inefficiency.')
+            raise ValueError(
+                "Given set of known RRsets (`instance`) is not a subset of RRsets referred to in"
+                " `validated_data`. While this would produce a correct result, this is illegal due to its"
+                " inefficiency."
+            )
 
 
         everything = instance_index.keys() | data_index.keys()
         everything = instance_index.keys() | data_index.keys()
         known = instance_index.keys()
         known = instance_index.keys()
@@ -303,64 +340,78 @@ class RRsetListSerializer(serializers.ListSerializer):
             instance_index[(subname, type_)].delete()
             instance_index[(subname, type_)].delete()
 
 
         for subname, type_ in created:
         for subname, type_ in created:
-            ret.append(self.child.create(
-                validated_data=data_index[(subname, type_)]
-            ))
+            ret.append(self.child.create(validated_data=data_index[(subname, type_)]))
 
 
         for subname, type_ in updated:
         for subname, type_ in updated:
-            ret.append(self.child.update(
-                instance=instance_index[(subname, type_)],
-                validated_data=data_index[(subname, type_)]
-            ))
+            ret.append(
+                self.child.update(
+                    instance=instance_index[(subname, type_)],
+                    validated_data=data_index[(subname, type_)],
+                )
+            )
 
 
         return ret
         return ret
 
 
     def save(self, **kwargs):
     def save(self, **kwargs):
-        kwargs.setdefault('domain', self.child.domain)
+        kwargs.setdefault("domain", self.child.domain)
         return super().save(**kwargs)
         return super().save(**kwargs)
 
 
 
 
 class RRsetSerializer(ConditionalExistenceModelSerializer):
 class RRsetSerializer(ConditionalExistenceModelSerializer):
-    domain = serializers.SlugRelatedField(read_only=True, slug_field='name')
+    domain = serializers.SlugRelatedField(read_only=True, slug_field="name")
     records = RRSerializer(many=True)
     records = RRSerializer(many=True)
     ttl = serializers.IntegerField(max_value=settings.MAXIMUM_TTL)
     ttl = serializers.IntegerField(max_value=settings.MAXIMUM_TTL)
 
 
     class Meta:
     class Meta:
         model = models.RRset
         model = models.RRset
-        fields = ('created', 'domain', 'subname', 'name', 'records', 'ttl', 'type', 'touched',)
+        fields = (
+            "created",
+            "domain",
+            "subname",
+            "name",
+            "records",
+            "ttl",
+            "type",
+            "touched",
+        )
         extra_kwargs = {
         extra_kwargs = {
-            'subname': {'required': False, 'default': NonBulkOnlyDefault('')}
+            "subname": {"required": False, "default": NonBulkOnlyDefault("")}
         }
         }
         list_serializer_class = RRsetListSerializer
         list_serializer_class = RRsetListSerializer
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
         try:
         try:
-            self.domain = self.context['domain']
+            self.domain = self.context["domain"]
         except KeyError:
         except KeyError:
-            raise ValueError('RRsetSerializer() must be given a domain object (to validate uniqueness constraints).')
-        self.minimum_ttl = self.context.get('minimum_ttl', self.domain.minimum_ttl)
+            raise ValueError(
+                "RRsetSerializer() must be given a domain object (to validate uniqueness constraints)."
+            )
+        self.minimum_ttl = self.context.get("minimum_ttl", self.domain.minimum_ttl)
 
 
     def get_fields(self):
     def get_fields(self):
         fields = super().get_fields()
         fields = super().get_fields()
-        fields['subname'].validators.append(ReadOnlyOnUpdateValidator())
-        fields['type'].validators.append(ReadOnlyOnUpdateValidator())
-        fields['ttl'].validators.append(MinValueValidator(limit_value=self.minimum_ttl))
+        fields["subname"].validators.append(ReadOnlyOnUpdateValidator())
+        fields["type"].validators.append(ReadOnlyOnUpdateValidator())
+        fields["ttl"].validators.append(MinValueValidator(limit_value=self.minimum_ttl))
         return fields
         return fields
 
 
     def get_validators(self):
     def get_validators(self):
         return [
         return [
             UniqueTogetherValidator(
             UniqueTogetherValidator(
                 self.domain.rrset_set,
                 self.domain.rrset_set,
-                ('subname', 'type'),
-                message='Another RRset with the same subdomain and type exists for this domain.',
+                ("subname", "type"),
+                message="Another RRset with the same subdomain and type exists for this domain.",
             ),
             ),
             ExclusionConstraintValidator(
             ExclusionConstraintValidator(
                 self.domain.rrset_set,
                 self.domain.rrset_set,
-                ('subname',),
-                exclusion_condition=('type', 'CNAME',),
-                message='RRset with conflicting type present: database ({types}).'
-                        ' (No other RRsets are allowed alongside CNAME.)',
+                ("subname",),
+                exclusion_condition=(
+                    "type",
+                    "CNAME",
+                ),
+                message="RRset with conflicting type present: database ({types})."
+                " (No other RRsets are allowed alongside CNAME.)",
             ),
             ),
         ]
         ]
 
 
@@ -369,20 +420,28 @@ class RRsetSerializer(ConditionalExistenceModelSerializer):
         if value not in models.RR_SET_TYPES_MANAGEABLE:
         if value not in models.RR_SET_TYPES_MANAGEABLE:
             # user cannot manage this type, let's try to tell her the reason
             # user cannot manage this type, let's try to tell her the reason
             if value in models.RR_SET_TYPES_AUTOMATIC:
             if value in models.RR_SET_TYPES_AUTOMATIC:
-                raise serializers.ValidationError(f'You cannot tinker with the {value} RR set. It is managed '
-                                                  f'automatically.')
-            elif value.startswith('TYPE'):
-                raise serializers.ValidationError('Generic type format is not supported.')
+                raise serializers.ValidationError(
+                    f"You cannot tinker with the {value} RR set. It is managed "
+                    f"automatically."
+                )
+            elif value.startswith("TYPE"):
+                raise serializers.ValidationError(
+                    "Generic type format is not supported."
+                )
             else:
             else:
-                raise serializers.ValidationError(f'The {value} RR set type is currently unsupported.')
+                raise serializers.ValidationError(
+                    f"The {value} RR set type is currently unsupported."
+                )
         return value
         return value
 
 
     def validate_records(self, value):
     def validate_records(self, value):
         # `records` is usually allowed to be empty (for idempotent delete), except for POST requests which are intended
         # `records` is usually allowed to be empty (for idempotent delete), except for POST requests which are intended
         # for RRset creation only. We use the fact that DRF generic views pass the request in the serializer context.
         # for RRset creation only. We use the fact that DRF generic views pass the request in the serializer context.
-        request = self.context.get('request')
-        if request and request.method == 'POST' and not value:
-            raise serializers.ValidationError('This field must not be empty when using POST.')
+        request = self.context.get("request")
+        if request and request.method == "POST" and not value:
+            raise serializers.ValidationError(
+                "This field must not be empty when using POST."
+            )
         return value
         return value
 
 
     def validate_subname(self, value):
     def validate_subname(self, value):
@@ -390,19 +449,27 @@ class RRsetSerializer(ConditionalExistenceModelSerializer):
             dns.name.from_text(value, dns.name.from_text(self.domain.name))
             dns.name.from_text(value, dns.name.from_text(self.domain.name))
         except dns.name.NameTooLong:
         except dns.name.NameTooLong:
             raise serializers.ValidationError(
             raise serializers.ValidationError(
-                'This field combined with the domain name must not exceed 255 characters.', code='name_too_long')
+                "This field combined with the domain name must not exceed 255 characters.",
+                code="name_too_long",
+            )
         return value
         return value
 
 
     def validate(self, attrs):
     def validate(self, attrs):
-        if 'records' in attrs:
+        if "records" in attrs:
             try:
             try:
-                type_ = attrs['type']
+                type_ = attrs["type"]
             except KeyError:  # on the RRsetDetail endpoint, the type is not in attrs
             except KeyError:  # on the RRsetDetail endpoint, the type is not in attrs
                 type_ = self.instance.type
                 type_ = self.instance.type
 
 
             try:
             try:
-                attrs['records'] = [{'content': models.RR.canonical_presentation_format(rr['content'], type_)}
-                                    for rr in attrs['records']]
+                attrs["records"] = [
+                    {
+                        "content": models.RR.canonical_presentation_format(
+                            rr["content"], type_
+                        )
+                    }
+                    for rr in attrs["records"]
+                ]
             except ValueError as ex:
             except ValueError as ex:
                 raise serializers.ValidationError(str(ex))
                 raise serializers.ValidationError(str(ex))
 
 
@@ -411,16 +478,24 @@ class RRsetSerializer(ConditionalExistenceModelSerializer):
             # There also seems to be a 32 byte (?) baseline requirement per RRset, plus the qname length, see
             # There also seems to be a 32 byte (?) baseline requirement per RRset, plus the qname length, see
             # https://lists.isc.org/pipermail/bind-users/2008-April/070148.html
             # https://lists.isc.org/pipermail/bind-users/2008-April/070148.html
             # The binary length of the record depends actually on the type, but it's never longer than vanilla len()
             # The binary length of the record depends actually on the type, but it's never longer than vanilla len()
-            qname = models.RRset.construct_name(attrs.get('subname', ''), self.domain.name)
-            conservative_total_length = 32 + len(qname) + sum(12 + len(rr['content']) for rr in attrs['records'])
+            qname = models.RRset.construct_name(
+                attrs.get("subname", ""), self.domain.name
+            )
+            conservative_total_length = (
+                32
+                + len(qname)
+                + sum(12 + len(rr["content"]) for rr in attrs["records"])
+            )
 
 
             # Add some leeway for RRSIG record (really ~110 bytes) and other data we have not thought of
             # Add some leeway for RRSIG record (really ~110 bytes) and other data we have not thought of
             conservative_total_length += 256
             conservative_total_length += 256
 
 
             excess_length = conservative_total_length - 65535  # max response size
             excess_length = conservative_total_length - 65535  # max response size
             if excess_length > 0:
             if excess_length > 0:
-                raise serializers.ValidationError(f'Total length of RRset exceeds limit by {excess_length} bytes.',
-                                                  code='max_length')
+                raise serializers.ValidationError(
+                    f"Total length of RRset exceeds limit by {excess_length} bytes.",
+                    code="max_length",
+                )
 
 
         return attrs
         return attrs
 
 
@@ -428,20 +503,20 @@ class RRsetSerializer(ConditionalExistenceModelSerializer):
         if isinstance(arg, models.RRset):
         if isinstance(arg, models.RRset):
             return arg.records.exists() if arg.pk else False
             return arg.records.exists() if arg.pk else False
         else:
         else:
-            return bool(arg.get('records')) if 'records' in arg.keys() else True
+            return bool(arg.get("records")) if "records" in arg.keys() else True
 
 
     def create(self, validated_data):
     def create(self, validated_data):
-        rrs_data = validated_data.pop('records')
+        rrs_data = validated_data.pop("records")
         rrset = models.RRset.objects.create(**validated_data)
         rrset = models.RRset.objects.create(**validated_data)
         self._set_all_record_contents(rrset, rrs_data)
         self._set_all_record_contents(rrset, rrs_data)
         return rrset
         return rrset
 
 
     def update(self, instance: models.RRset, validated_data):
     def update(self, instance: models.RRset, validated_data):
-        rrs_data = validated_data.pop('records', None)
+        rrs_data = validated_data.pop("records", None)
         if rrs_data is not None:
         if rrs_data is not None:
             self._set_all_record_contents(instance, rrs_data)
             self._set_all_record_contents(instance, rrs_data)
 
 
-        ttl = validated_data.pop('ttl', None)
+        ttl = validated_data.pop("ttl", None)
         if ttl and instance.ttl != ttl:
         if ttl and instance.ttl != ttl:
             instance.ttl = ttl
             instance.ttl = ttl
             instance.save()  # also updates instance.touched
             instance.save()  # also updates instance.touched
@@ -452,7 +527,7 @@ class RRsetSerializer(ConditionalExistenceModelSerializer):
         return instance
         return instance
 
 
     def save(self, **kwargs):
     def save(self, **kwargs):
-        kwargs.setdefault('domain', self.domain)
+        kwargs.setdefault("domain", self.domain)
         return super().save(**kwargs)
         return super().save(**kwargs)
 
 
     @staticmethod
     @staticmethod
@@ -463,8 +538,8 @@ class RRsetSerializer(ConditionalExistenceModelSerializer):
         :param rrset: the RRset at which we overwrite all RRs
         :param rrset: the RRset at which we overwrite all RRs
         :param rrs: list of RR representations
         :param rrs: list of RR representations
         """
         """
-        record_contents = [rr['content'] for rr in rrs]
+        record_contents = [rr["content"] for rr in rrs]
         try:
         try:
             rrset.save_records(record_contents)
             rrset.save_records(record_contents)
         except django.core.exceptions.ValidationError as e:
         except django.core.exceptions.ValidationError as e:
-            raise serializers.ValidationError(e.messages, code='record-content')
+            raise serializers.ValidationError(e.messages, code="record-content")

+ 32 - 13
api/desecapi/serializers/tokens.py

@@ -6,15 +6,27 @@ from desecapi.models import Token, TokenDomainPolicy
 
 
 
 
 class TokenSerializer(serializers.ModelSerializer):
 class TokenSerializer(serializers.ModelSerializer):
-    allowed_subnets = serializers.ListField(child=netfields_rf.CidrAddressField(), required=False)
-    token = serializers.ReadOnlyField(source='plain')
+    allowed_subnets = serializers.ListField(
+        child=netfields_rf.CidrAddressField(), required=False
+    )
+    token = serializers.ReadOnlyField(source="plain")
     is_valid = serializers.ReadOnlyField()
     is_valid = serializers.ReadOnlyField()
 
 
     class Meta:
     class Meta:
         model = Token
         model = Token
-        fields = ('id', 'created', 'last_used', 'max_age', 'max_unused_period', 'name', 'perm_manage_tokens',
-                  'allowed_subnets', 'is_valid', 'token',)
-        read_only_fields = ('id', 'created', 'last_used', 'token')
+        fields = (
+            "id",
+            "created",
+            "last_used",
+            "max_age",
+            "max_unused_period",
+            "name",
+            "perm_manage_tokens",
+            "allowed_subnets",
+            "is_valid",
+            "token",
+        )
+        read_only_fields = ("id", "created", "last_used", "token")
 
 
     def __init__(self, *args, include_plain=False, **kwargs):
     def __init__(self, *args, include_plain=False, **kwargs):
         self.include_plain = include_plain
         self.include_plain = include_plain
@@ -23,29 +35,36 @@ class TokenSerializer(serializers.ModelSerializer):
     def get_fields(self):
     def get_fields(self):
         fields = super().get_fields()
         fields = super().get_fields()
         if not self.include_plain:
         if not self.include_plain:
-            fields.pop('token')
+            fields.pop("token")
         return fields
         return fields
 
 
 
 
 class DomainSlugRelatedField(serializers.SlugRelatedField):
 class DomainSlugRelatedField(serializers.SlugRelatedField):
-
     def get_queryset(self):
     def get_queryset(self):
-        return self.context['request'].user.domains
+        return self.context["request"].user.domains
 
 
 
 
 class TokenDomainPolicySerializer(serializers.ModelSerializer):
 class TokenDomainPolicySerializer(serializers.ModelSerializer):
-    domain = DomainSlugRelatedField(allow_null=True, slug_field='name')
+    domain = DomainSlugRelatedField(allow_null=True, slug_field="name")
 
 
     class Meta:
     class Meta:
         model = TokenDomainPolicy
         model = TokenDomainPolicy
-        fields = ('domain', 'perm_dyndns', 'perm_rrsets',)
+        fields = (
+            "domain",
+            "perm_dyndns",
+            "perm_rrsets",
+        )
 
 
     def to_internal_value(self, data):
     def to_internal_value(self, data):
-        return {**super().to_internal_value(data),
-                'token': self.context['request'].user.token_set.get(id=self.context['view'].kwargs['token_id'])}
+        return {
+            **super().to_internal_value(data),
+            "token": self.context["request"].user.token_set.get(
+                id=self.context["view"].kwargs["token_id"]
+            ),
+        }
 
 
     def save(self, **kwargs):
     def save(self, **kwargs):
         try:
         try:
             return super().save(**kwargs)
             return super().save(**kwargs)
         except django.core.exceptions.ValidationError as exc:
         except django.core.exceptions.ValidationError as exc:
-            raise serializers.ValidationError(exc.message_dict, code='precedence')
+            raise serializers.ValidationError(exc.message_dict, code="precedence")

+ 33 - 15
api/desecapi/serializers/users.py

@@ -19,8 +19,8 @@ class ChangeEmailSerializer(serializers.Serializer):
     new_email = serializers.EmailField()
     new_email = serializers.EmailField()
 
 
     def validate_new_email(self, value):
     def validate_new_email(self, value):
-        if value == self.context['request'].user.email:
-            raise serializers.ValidationError('Email address unchanged.')
+        if value == self.context["request"].user.email:
+            raise serializers.ValidationError("Email address unchanged.")
         return value
         return value
 
 
 
 
@@ -29,11 +29,21 @@ class ResetPasswordSerializer(EmailSerializer):
 
 
 
 
 class UserSerializer(serializers.ModelSerializer):
 class UserSerializer(serializers.ModelSerializer):
-
     class Meta:
     class Meta:
         model = User
         model = User
-        fields = ('created', 'email', 'id', 'limit_domains', 'outreach_preference',)
-        read_only_fields = ('created', 'email', 'id', 'limit_domains',)
+        fields = (
+            "created",
+            "email",
+            "id",
+            "limit_domains",
+            "outreach_preference",
+        )
+        read_only_fields = (
+            "created",
+            "email",
+            "id",
+            "limit_domains",
+        )
 
 
     def validate_password(self, value):
     def validate_password(self, value):
         if value is not None:
         if value is not None:
@@ -50,11 +60,17 @@ class RegisterAccountSerializer(UserSerializer):
 
 
     class Meta:
     class Meta:
         model = UserSerializer.Meta.model
         model = UserSerializer.Meta.model
-        fields = ('email', 'password', 'domain', 'captcha', 'outreach_preference',)
+        fields = (
+            "email",
+            "password",
+            "domain",
+            "captcha",
+            "outreach_preference",
+        )
         extra_kwargs = {
         extra_kwargs = {
-            'password': {
-                'write_only': True,  # Do not expose password field
-                'allow_null': True,
+            "password": {
+                "write_only": True,  # Do not expose password field
+                "allow_null": True,
             }
             }
         }
         }
 
 
@@ -63,14 +79,16 @@ class RegisterAccountSerializer(UserSerializer):
         try:
         try:
             serializer.is_valid(raise_exception=True)
             serializer.is_valid(raise_exception=True)
         except serializers.ValidationError:
         except serializers.ValidationError:
-            raise serializers.ValidationError(serializer.default_error_messages['name_unavailable'],
-                                              code='name_unavailable')
+            raise serializers.ValidationError(
+                serializer.default_error_messages["name_unavailable"],
+                code="name_unavailable",
+            )
         return value
         return value
 
 
     def create(self, validated_data):
     def create(self, validated_data):
-        validated_data.pop('domain', None)
+        validated_data.pop("domain", None)
         # If validated_data['captcha'] exists, the captcha was also validated, so we can set the user to verified
         # If validated_data['captcha'] exists, the captcha was also validated, so we can set the user to verified
-        if 'captcha' in validated_data:
-            validated_data.pop('captcha')
-            validated_data['needs_captcha'] = False
+        if "captcha" in validated_data:
+            validated_data.pop("captcha")
+            validated_data["needs_captcha"] = False
         return super().create(validated_data)
         return super().create(validated_data)

+ 3 - 1
api/desecapi/signals.py

@@ -5,5 +5,7 @@ from desecapi import models
 
 
 
 
 @receiver(post_save, sender=models.Domain, dispatch_uid=__name__)
 @receiver(post_save, sender=models.Domain, dispatch_uid=__name__)
-def domain_handler(sender, instance: models.Domain, created, raw, using, update_fields, **kwargs):
+def domain_handler(
+    sender, instance: models.Domain, created, raw, using, update_fields, **kwargs
+):
     pass
     pass

+ 9 - 3
api/desecapi/templatetags/action_extras.py

@@ -9,9 +9,15 @@ register = template.Library()
 
 
 @register.simple_tag
 @register.simple_tag
 def action_link(action_serializer, idx=None):
 def action_link(action_serializer, idx=None):
-    view_name = f'v1:confirm-{action_serializer.reason}'
-    code = action_serializer.data['code'] if idx is None else action_serializer.data[idx]['code']
-    return f'https://desec.{settings.DESECSTACK_DOMAIN}' + reverse(view_name, args=[code])
+    view_name = f"v1:confirm-{action_serializer.reason}"
+    code = (
+        action_serializer.data["code"]
+        if idx is None
+        else action_serializer.data[idx]["code"]
+    )
+    return f"https://desec.{settings.DESECSTACK_DOMAIN}" + reverse(
+        view_name, args=[code]
+    )
 
 
 
 
 @register.simple_tag
 @register.simple_tag

+ 4 - 4
api/desecapi/templatetags/sepa_extras.py

@@ -8,10 +8,10 @@ register = template.Library()
 
 
 def clean(value):
 def clean(value):
     """Replaces non-ascii characters with their closest ascii
     """Replaces non-ascii characters with their closest ascii
-       representation and then removes everything but [A-Za-z0-9 ]"""
-    normalized = unicodedata.normalize('NFKD', value)
-    cleaned = re.sub(r'[^A-Za-z0-9 ]', '', normalized)
+    representation and then removes everything but [A-Za-z0-9 ]"""
+    normalized = unicodedata.normalize("NFKD", value)
+    cleaned = re.sub(r"[^A-Za-z0-9 ]", "", normalized)
     return cleaned
     return cleaned
 
 
 
 
-register.filter('clean', clean)
+register.filter("clean", clean)

File diff suppressed because it is too large
+ 419 - 217
api/desecapi/tests/base.py


+ 125 - 51
api/desecapi/tests/test_authentication.py

@@ -14,7 +14,7 @@ class DynUpdateAuthenticationTestCase(DynDomainOwnerTestCase):
 
 
     def _get_dyndns12(self):
     def _get_dyndns12(self):
         with self.assertPdnsNoRequestsBut(self.requests_desec_rr_sets_update()):
         with self.assertPdnsNoRequestsBut(self.requests_desec_rr_sets_update()):
-            return self.client.get(self.reverse('v1:dyndns12update'))
+            return self.client.get(self.reverse("v1:dyndns12update"))
 
 
     def assertDynDNS12Status(self, code=HTTP_200_OK, authorization=None):
     def assertDynDNS12Status(self, code=HTTP_200_OK, authorization=None):
         if authorization:
         if authorization:
@@ -27,43 +27,61 @@ class DynUpdateAuthenticationTestCase(DynDomainOwnerTestCase):
             self.client.set_credentials_basic_auth(username, token)
             self.client.set_credentials_basic_auth(username, token)
             self.assertDynDNS12Status(code)
             self.assertDynDNS12Status(code)
 
 
-        assertDynDNS12AuthenticationStatus('', self.token.plain, HTTP_200_OK)
-        assertDynDNS12AuthenticationStatus(self.owner.get_username(), self.token.plain, HTTP_200_OK)
-        assertDynDNS12AuthenticationStatus(self.my_domain.name, self.token.plain, HTTP_200_OK)
-        assertDynDNS12AuthenticationStatus(' ' + self.my_domain.name, self.token.plain, HTTP_401_UNAUTHORIZED)
-        assertDynDNS12AuthenticationStatus('wrong', self.token.plain, HTTP_401_UNAUTHORIZED)
-        assertDynDNS12AuthenticationStatus('', 'wrong', HTTP_401_UNAUTHORIZED)
-        assertDynDNS12AuthenticationStatus(self.user.get_username(), 'wrong', HTTP_401_UNAUTHORIZED)
+        assertDynDNS12AuthenticationStatus("", self.token.plain, HTTP_200_OK)
+        assertDynDNS12AuthenticationStatus(
+            self.owner.get_username(), self.token.plain, HTTP_200_OK
+        )
+        assertDynDNS12AuthenticationStatus(
+            self.my_domain.name, self.token.plain, HTTP_200_OK
+        )
+        assertDynDNS12AuthenticationStatus(
+            " " + self.my_domain.name, self.token.plain, HTTP_401_UNAUTHORIZED
+        )
+        assertDynDNS12AuthenticationStatus(
+            "wrong", self.token.plain, HTTP_401_UNAUTHORIZED
+        )
+        assertDynDNS12AuthenticationStatus("", "wrong", HTTP_401_UNAUTHORIZED)
+        assertDynDNS12AuthenticationStatus(
+            self.user.get_username(), "wrong", HTTP_401_UNAUTHORIZED
+        )
 
 
     def test_malformed_basic_auth(self):
     def test_malformed_basic_auth(self):
         for authorization in [
         for authorization in [
-            'asdf:asdf:sadf',
-            'asdf',
-            'bull[%]shit',
-            '你好',
-            '💩💩💩💩',
-            '💩💩:💩💩',
+            "asdf:asdf:sadf",
+            "asdf",
+            "bull[%]shit",
+            "你好",
+            "💩💩💩💩",
+            "💩💩:💩💩",
         ]:
         ]:
-            self.assertDynDNS12Status(authorization=authorization, code=HTTP_401_UNAUTHORIZED)
+            self.assertDynDNS12Status(
+                authorization=authorization, code=HTTP_401_UNAUTHORIZED
+            )
 
 
 
 
 class TokenAuthenticationTestCase(DynDomainOwnerTestCase):
 class TokenAuthenticationTestCase(DynDomainOwnerTestCase):
-
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
         # Refresh token from database, but keep plain value
         # Refresh token from database, but keep plain value
-        self.token, self.token.plain = Token.objects.get(pk=self.token.pk), self.token.plain
+        self.token, self.token.plain = (
+            Token.objects.get(pk=self.token.pk),
+            self.token.plain,
+        )
 
 
-    def assertAuthenticationStatus(self, code, plain=None, expired=False ,**kwargs):
+    def assertAuthenticationStatus(self, code, plain=None, expired=False, **kwargs):
         plain = plain or self.token.plain
         plain = plain or self.token.plain
         self.client.set_credentials_token_auth(plain)
         self.client.set_credentials_token_auth(plain)
 
 
         # only forward REMOTE_ADDR if not None
         # only forward REMOTE_ADDR if not None
-        if kwargs.get('REMOTE_ADDR') is None:
-            kwargs.pop('REMOTE_ADDR', None)
-
-        response = self.client.get(self.reverse('v1:root'), **kwargs)
-        body = json.dumps({'detail': 'Invalid token.'}) if code == HTTP_401_UNAUTHORIZED else None
+        if kwargs.get("REMOTE_ADDR") is None:
+            kwargs.pop("REMOTE_ADDR", None)
+
+        response = self.client.get(self.reverse("v1:root"), **kwargs)
+        body = (
+            json.dumps({"detail": "Invalid token."})
+            if code == HTTP_401_UNAUTHORIZED
+            else None
+        )
         self.assertResponse(response, code, body)
         self.assertResponse(response, code, body)
 
 
         if expired:
         if expired:
@@ -78,16 +96,18 @@ class TokenAuthenticationTestCase(DynDomainOwnerTestCase):
     def test_token_subnets(self):
     def test_token_subnets(self):
         datas = [  # Format: allowed_subnets, status, client_ip | None, [client_ip, ...]
         datas = [  # Format: allowed_subnets, status, client_ip | None, [client_ip, ...]
             ([], HTTP_401_UNAUTHORIZED, None),
             ([], HTTP_401_UNAUTHORIZED, None),
-            (['127.0.0.1'], HTTP_200_OK, None),
-            (['1.2.3.4'], HTTP_401_UNAUTHORIZED, None),
-            (['1.2.3.4'], HTTP_200_OK, '1.2.3.4'),
-            (['1.2.3.0/24'], HTTP_200_OK, '1.2.3.4'),
-            (['1.2.3.0/24'], HTTP_401_UNAUTHORIZED, 'bade::affe'),
-            (['bade::/64'], HTTP_200_OK, 'bade::affe'),
-            (['bade::/64', '1.2.3.0/24'], HTTP_200_OK, 'bade::affe', '1.2.3.66'),
+            (["127.0.0.1"], HTTP_200_OK, None),
+            (["1.2.3.4"], HTTP_401_UNAUTHORIZED, None),
+            (["1.2.3.4"], HTTP_200_OK, "1.2.3.4"),
+            (["1.2.3.0/24"], HTTP_200_OK, "1.2.3.4"),
+            (["1.2.3.0/24"], HTTP_401_UNAUTHORIZED, "bade::affe"),
+            (["bade::/64"], HTTP_200_OK, "bade::affe"),
+            (["bade::/64", "1.2.3.0/24"], HTTP_200_OK, "bade::affe", "1.2.3.66"),
         ]
         ]
 
 
-        for allowed_subnets, status, client_ips in ((*data[:2], data[2:]) for data in datas):
+        for allowed_subnets, status, client_ips in (
+            (*data[:2], data[2:]) for data in datas
+        ):
             self.token.allowed_subnets = allowed_subnets
             self.token.allowed_subnets = allowed_subnets
             self.token.save()
             self.token.save()
             for client_ip in client_ips:
             for client_ip in client_ips:
@@ -99,7 +119,10 @@ class TokenAuthenticationTestCase(DynDomainOwnerTestCase):
         self.token.save()
         self.token.save()
 
 
         self.assertAuthenticationStatus(HTTP_200_OK)
         self.assertAuthenticationStatus(HTTP_200_OK)
-        with mock.patch('desecapi.models.timezone.now', return_value=timezone.now() + timedelta(days=3650)):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=timezone.now() + timedelta(days=3650),
+        ):
             self.assertAuthenticationStatus(HTTP_200_OK)
             self.assertAuthenticationStatus(HTTP_200_OK)
 
 
         # Maximum age zero: token cannot be used
         # Maximum age zero: token cannot be used
@@ -113,9 +136,15 @@ class TokenAuthenticationTestCase(DynDomainOwnerTestCase):
         self.token.save()
         self.token.save()
 
 
         second = timedelta(seconds=1)
         second = timedelta(seconds=1)
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.created + period - second):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.created + period - second,
+        ):
             self.assertAuthenticationStatus(HTTP_200_OK)
             self.assertAuthenticationStatus(HTTP_200_OK)
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.created + period + second):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.created + period + second,
+        ):
             self.assertAuthenticationStatus(HTTP_401_UNAUTHORIZED, expired=True)
             self.assertAuthenticationStatus(HTTP_401_UNAUTHORIZED, expired=True)
 
 
     def test_token_max_unused_period(self):
     def test_token_max_unused_period(self):
@@ -134,24 +163,46 @@ class TokenAuthenticationTestCase(DynDomainOwnerTestCase):
 
 
         # Can't use after period if token was never used (last_used is None)
         # Can't use after period if token was never used (last_used is None)
         self.assertIsNone(self.token.last_used)
         self.assertIsNone(self.token.last_used)
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.created + period + second):
-            self.assertAuthenticationStatus(HTTP_401_UNAUTHORIZED, plain=plain, expired=True)
-            self.assertIsNone(Token.objects.get(pk=self.token.pk).last_used)  # unchanged
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.created + period + second,
+        ):
+            self.assertAuthenticationStatus(
+                HTTP_401_UNAUTHORIZED, plain=plain, expired=True
+            )
+            self.assertIsNone(
+                Token.objects.get(pk=self.token.pk).last_used
+            )  # unchanged
 
 
         # Can use after half the period
         # Can use after half the period
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.created + period/2):
+        with mock.patch(
+            "desecapi.models.timezone.now", return_value=self.token.created + period / 2
+        ):
             self.assertAuthenticationStatus(HTTP_200_OK, plain=plain)
             self.assertAuthenticationStatus(HTTP_200_OK, plain=plain)
         self.token = Token.objects.get(pk=self.token.pk)  # update last_used field
         self.token = Token.objects.get(pk=self.token.pk)  # update last_used field
 
 
         # Can't use once another period is over
         # Can't use once another period is over
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.last_used + period + second):
-            self.assertAuthenticationStatus(HTTP_401_UNAUTHORIZED, plain=plain, expired=True)
-            self.assertEqual(self.token.last_used, Token.objects.get(pk=self.token.pk).last_used)  # unchanged
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.last_used + period + second,
+        ):
+            self.assertAuthenticationStatus(
+                HTTP_401_UNAUTHORIZED, plain=plain, expired=True
+            )
+            self.assertEqual(
+                self.token.last_used, Token.objects.get(pk=self.token.pk).last_used
+            )  # unchanged
 
 
         # ... but one second before, and also for one more period
         # ... but one second before, and also for one more period
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.last_used + period - second):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.last_used + period - second,
+        ):
             self.assertAuthenticationStatus(HTTP_200_OK, plain=plain)
             self.assertAuthenticationStatus(HTTP_200_OK, plain=plain)
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.last_used + 2*period - 2*second):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.last_used + 2 * period - 2 * second,
+        ):
             self.assertAuthenticationStatus(HTTP_200_OK, plain=plain)
             self.assertAuthenticationStatus(HTTP_200_OK, plain=plain)
 
 
         # No maximum age: can use now and in ten years
         # No maximum age: can use now and in ten years
@@ -159,7 +210,10 @@ class TokenAuthenticationTestCase(DynDomainOwnerTestCase):
         self.token.save()
         self.token.save()
 
 
         self.assertAuthenticationStatus(HTTP_200_OK, plain=plain)
         self.assertAuthenticationStatus(HTTP_200_OK, plain=plain)
-        with mock.patch('desecapi.models.timezone.now', return_value=timezone.now() + timedelta(days=3650)):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=timezone.now() + timedelta(days=3650),
+        ):
             self.assertAuthenticationStatus(HTTP_200_OK, plain=plain)
             self.assertAuthenticationStatus(HTTP_200_OK, plain=plain)
 
 
     def test_token_max_age_max_unused_period(self):
     def test_token_max_age_max_unused_period(self):
@@ -169,28 +223,48 @@ class TokenAuthenticationTestCase(DynDomainOwnerTestCase):
         self.token.save()
         self.token.save()
 
 
         # max_unused_period wins if tighter than max_age
         # max_unused_period wins if tighter than max_age
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.created + 1.25*hour):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.created + 1.25 * hour,
+        ):
             self.assertAuthenticationStatus(HTTP_401_UNAUTHORIZED, expired=True)
             self.assertAuthenticationStatus(HTTP_401_UNAUTHORIZED, expired=True)
 
 
         # Can use immediately
         # Can use immediately
         self.assertAuthenticationStatus(HTTP_200_OK)
         self.assertAuthenticationStatus(HTTP_200_OK)
 
 
         # Can use continuously within max_unused_period
         # Can use continuously within max_unused_period
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.created + 0.75*hour):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.created + 0.75 * hour,
+        ):
             self.assertAuthenticationStatus(HTTP_200_OK)
             self.assertAuthenticationStatus(HTTP_200_OK)
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.created + 1.5*hour):
+        with mock.patch(
+            "desecapi.models.timezone.now", return_value=self.token.created + 1.5 * hour
+        ):
             self.assertAuthenticationStatus(HTTP_200_OK)
             self.assertAuthenticationStatus(HTTP_200_OK)
 
 
         # max_unused_period wins again if tighter than max_age
         # max_unused_period wins again if tighter than max_age
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.created + 2.75*hour):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.created + 2.75 * hour,
+        ):
             self.assertAuthenticationStatus(HTTP_401_UNAUTHORIZED, expired=True)
             self.assertAuthenticationStatus(HTTP_401_UNAUTHORIZED, expired=True)
 
 
         # Can use continuously within max_unused_period
         # Can use continuously within max_unused_period
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.created + 2.25*hour):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.created + 2.25 * hour,
+        ):
             self.assertAuthenticationStatus(HTTP_200_OK)
             self.assertAuthenticationStatus(HTTP_200_OK)
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.created + 2.75*hour):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.created + 2.75 * hour,
+        ):
             self.assertAuthenticationStatus(HTTP_200_OK)
             self.assertAuthenticationStatus(HTTP_200_OK)
 
 
         # max_age wins again if tighter than max_unused_period
         # max_age wins again if tighter than max_unused_period
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.created + 3.25*hour):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.created + 3.25 * hour,
+        ):
             self.assertAuthenticationStatus(HTTP_401_UNAUTHORIZED, expired=True)
             self.assertAuthenticationStatus(HTTP_401_UNAUTHORIZED, expired=True)

+ 22 - 20
api/desecapi/tests/test_captcha.py

@@ -16,9 +16,8 @@ from desecapi.tests.base import DesecTestCase
 
 
 
 
 class CaptchaClient(APIClient):
 class CaptchaClient(APIClient):
-
     def obtain(self, **kwargs):
     def obtain(self, **kwargs):
-        return self.post(reverse('v1:captcha'), data=kwargs)
+        return self.post(reverse("v1:captcha"), data=kwargs)
 
 
 
 
 class CaptchaModelTestCase(TestCase):
 class CaptchaModelTestCase(TestCase):
@@ -27,13 +26,13 @@ class CaptchaModelTestCase(TestCase):
     def test_random_initialization(self):
     def test_random_initialization(self):
         captcha = [self.captcha_class() for _ in range(2)]
         captcha = [self.captcha_class() for _ in range(2)]
         self.assertNotEqual(captcha[0].content, None)
         self.assertNotEqual(captcha[0].content, None)
-        self.assertNotEqual(captcha[0].content, '')
+        self.assertNotEqual(captcha[0].content, "")
         self.assertNotEqual(captcha[0].content, captcha[1].content)
         self.assertNotEqual(captcha[0].content, captcha[1].content)
 
 
     def test_verify_solution(self):
     def test_verify_solution(self):
         for _ in range(10):
         for _ in range(10):
             c = self.captcha_class.objects.create()
             c = self.captcha_class.objects.create()
-            self.assertFalse(c.verify('likely the wrong solution!'))
+            self.assertFalse(c.verify("likely the wrong solution!"))
             c = self.captcha_class.objects.create()
             c = self.captcha_class.objects.create()
             self.assertTrue(c.verify(c.content))
             self.assertTrue(c.verify(c.content))
 
 
@@ -53,7 +52,7 @@ class CaptchaWorkflowTestCase(DesecTestCase):
         :return: whether the id/solution pair is correct
         :return: whether the id/solution pair is correct
         """
         """
         # use the serializer to validate the solution; id is validated implicitly on DB lookup
         # use the serializer to validate the solution; id is validated implicitly on DB lookup
-        return self.serializer_class(data={'id': id, 'solution': solution}).is_valid()
+        return self.serializer_class(data={"id": id, "solution": solution}).is_valid()
 
 
     def obtain(self):
     def obtain(self):
         if self.kind is None:
         if self.kind is None:
@@ -63,54 +62,57 @@ class CaptchaWorkflowTestCase(DesecTestCase):
 
 
     def test_obtain(self):
     def test_obtain(self):
         response = self.obtain()
         response = self.obtain()
-        self.assertContains(response, 'id', status_code=status.HTTP_201_CREATED)
-        self.assertContains(response, 'challenge', status_code=status.HTTP_201_CREATED)
-        self.assertTrue('content' not in response.data)
+        self.assertContains(response, "id", status_code=status.HTTP_201_CREATED)
+        self.assertContains(response, "challenge", status_code=status.HTTP_201_CREATED)
+        self.assertTrue("content" not in response.data)
         self.assertTrue(len(response.data) == 3)
         self.assertTrue(len(response.data) == 3)
         self.assertEqual(self.captcha_class.objects.all().count(), 1)
         self.assertEqual(self.captcha_class.objects.all().count(), 1)
         # use the value of f'<img src="data:image/png;base64,{response.data["challenge"].decode()}" />'
         # use the value of f'<img src="data:image/png;base64,{response.data["challenge"].decode()}" />'
         # to display the CAPTCHA in a browser
         # to display the CAPTCHA in a browser
 
 
     def test_verify_correct(self):
     def test_verify_correct(self):
-        id = self.obtain().data['id']
+        id = self.obtain().data["id"]
         correct_solution = Captcha.objects.get(id=id).content
         correct_solution = Captcha.objects.get(id=id).content
         self.assertTrue(self.verify(id, correct_solution))
         self.assertTrue(self.verify(id, correct_solution))
 
 
     def test_verify_incorrect(self):
     def test_verify_incorrect(self):
-        id = self.obtain().data['id']
-        wrong_solution = 'most certainly wrong!'
+        id = self.obtain().data["id"]
+        wrong_solution = "most certainly wrong!"
         self.assertFalse(self.verify(id, wrong_solution))
         self.assertFalse(self.verify(id, wrong_solution))
 
 
     def test_expired(self):
     def test_expired(self):
-        id = self.obtain().data['id']
+        id = self.obtain().data["id"]
         correct_solution = Captcha.objects.get(id=id).content
         correct_solution = Captcha.objects.get(id=id).content
 
 
-        with mock.patch('desecapi.models.timezone.now', return_value=timezone.now() + settings.CAPTCHA_VALIDITY_PERIOD):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=timezone.now() + settings.CAPTCHA_VALIDITY_PERIOD,
+        ):
             self.assertFalse(self.verify(id, correct_solution))
             self.assertFalse(self.verify(id, correct_solution))
 
 
 
 
 class ImageCaptchaWorkflowTestCase(CaptchaWorkflowTestCase):
 class ImageCaptchaWorkflowTestCase(CaptchaWorkflowTestCase):
-    kind = 'image'
+    kind = "image"
 
 
     def test_length(self):
     def test_length(self):
-        self.assertTrue(5000 < len(self.obtain().data['challenge']) < 50000)
+        self.assertTrue(5000 < len(self.obtain().data["challenge"]) < 50000)
 
 
     def test_parses(self):
     def test_parses(self):
         for _ in range(10):
         for _ in range(10):
             # use the show method on the Image object to see the actual image during test run
             # use the show method on the Image object to see the actual image during test run
             # This also allows an impression of how the CAPTCHAs will look like.
             # This also allows an impression of how the CAPTCHAs will look like.
             cap = self.obtain().data
             cap = self.obtain().data
-            challenge = b64decode(cap['challenge'])
+            challenge = b64decode(cap["challenge"])
             Image.open(BytesIO(challenge))  # .show()
             Image.open(BytesIO(challenge))  # .show()
 
 
 
 
 class AudioCaptchaWorkflowTestCase(CaptchaWorkflowTestCase):
 class AudioCaptchaWorkflowTestCase(CaptchaWorkflowTestCase):
-    kind = 'audio'
+    kind = "audio"
 
 
     def test_length(self):
     def test_length(self):
-        self.assertTrue(10**5 < len(self.obtain().data['challenge']) < 10**6)
+        self.assertTrue(10 ** 5 < len(self.obtain().data["challenge"]) < 10 ** 6)
 
 
     def test_parses(self):
     def test_parses(self):
         for _ in range(10):
         for _ in range(10):
-            challenge = b64decode(self.obtain().data['challenge'])
-            self.assertTrue(b'WAVE' in challenge)
+            challenge = b64decode(self.obtain().data["challenge"])
+            self.assertTrue(b"WAVE" in challenge)

+ 46 - 16
api/desecapi/tests/test_chores.py

@@ -11,40 +11,70 @@ from desecapi.models import Captcha, User
 class ChoresCommandTest(TestCase):
 class ChoresCommandTest(TestCase):
     @override_settings(CAPTCHA_VALIDITY_PERIOD=timezone.timedelta(hours=1))
     @override_settings(CAPTCHA_VALIDITY_PERIOD=timezone.timedelta(hours=1))
     def test_captcha_cleanup(self):
     def test_captcha_cleanup(self):
-        faketime = timezone.now() - settings.CAPTCHA_VALIDITY_PERIOD - timezone.timedelta(seconds=1)
-        with mock.patch('django.db.models.fields.timezone.now', return_value=faketime):
+        faketime = (
+            timezone.now()
+            - settings.CAPTCHA_VALIDITY_PERIOD
+            - timezone.timedelta(seconds=1)
+        )
+        with mock.patch("django.db.models.fields.timezone.now", return_value=faketime):
             captcha1 = Captcha.objects.create()
             captcha1 = Captcha.objects.create()
 
 
         captcha2 = Captcha.objects.create()
         captcha2 = Captcha.objects.create()
-        self.assertGreaterEqual((captcha2.created - captcha1.created).total_seconds(), 3601)
+        self.assertGreaterEqual(
+            (captcha2.created - captcha1.created).total_seconds(), 3601
+        )
 
 
-        management.call_command('chores')
+        management.call_command("chores")
         self.assertEqual(list(Captcha.objects.all()), [captcha2])
         self.assertEqual(list(Captcha.objects.all()), [captcha2])
 
 
-    @override_settings(VALIDITY_PERIOD_VERIFICATION_SIGNATURE=timezone.timedelta(hours=1))
+    @override_settings(
+        VALIDITY_PERIOD_VERIFICATION_SIGNATURE=timezone.timedelta(hours=1)
+    )
     def test_inactive_user_cleanup(self):
     def test_inactive_user_cleanup(self):
         def create_users(kind):
         def create_users(kind):
             logintime = timezone.now() + timezone.timedelta(seconds=5)
             logintime = timezone.now() + timezone.timedelta(seconds=5)
             kwargs_list = [
             kwargs_list = [
-                dict(email=f'user1+{kind}@example.com', is_active=None, last_login=None),
-                dict(email=f'user2+{kind}@example.com', is_active=None, last_login=logintime),
-                dict(email=f'user3+{kind}@example.com', is_active=False, last_login=None),
-                dict(email=f'user4+{kind}@example.com', is_active=False, last_login=logintime),
-                dict(email=f'user5+{kind}@example.com', is_active=True, last_login=None),
-                dict(email=f'user6+{kind}@example.com', is_active=True, last_login=logintime),
+                dict(
+                    email=f"user1+{kind}@example.com", is_active=None, last_login=None
+                ),
+                dict(
+                    email=f"user2+{kind}@example.com",
+                    is_active=None,
+                    last_login=logintime,
+                ),
+                dict(
+                    email=f"user3+{kind}@example.com", is_active=False, last_login=None
+                ),
+                dict(
+                    email=f"user4+{kind}@example.com",
+                    is_active=False,
+                    last_login=logintime,
+                ),
+                dict(
+                    email=f"user5+{kind}@example.com", is_active=True, last_login=None
+                ),
+                dict(
+                    email=f"user6+{kind}@example.com",
+                    is_active=True,
+                    last_login=logintime,
+                ),
             ]
             ]
             return (User.objects.create(**kwargs) for kwargs in kwargs_list)
             return (User.objects.create(**kwargs) for kwargs in kwargs_list)
 
 
         # Old users
         # Old users
-        faketime = timezone.now() - settings.VALIDITY_PERIOD_VERIFICATION_SIGNATURE - timezone.timedelta(seconds=1)
-        with mock.patch('django.db.models.fields.timezone.now', return_value=faketime):
-            expired_user, *_ = create_users('old')
+        faketime = (
+            timezone.now()
+            - settings.VALIDITY_PERIOD_VERIFICATION_SIGNATURE
+            - timezone.timedelta(seconds=1)
+        )
+        with mock.patch("django.db.models.fields.timezone.now", return_value=faketime):
+            expired_user, *_ = create_users("old")
 
 
         # New users
         # New users
-        create_users('new')
+        create_users("new")
 
 
         all_users = set(User.objects.all())
         all_users = set(User.objects.all())
 
 
-        management.call_command('chores')
+        management.call_command("chores")
         # Check that only the expired user was deleted
         # Check that only the expired user was deleted
         self.assertEqual(all_users - set(User.objects.all()), {expired_user})
         self.assertEqual(all_users - set(User.objects.all()), {expired_user})

+ 21 - 11
api/desecapi/tests/test_crypto.py

@@ -7,25 +7,33 @@ from desecapi import crypto
 
 
 
 
 class CryptoTestCase(TestCase):
 class CryptoTestCase(TestCase):
-    context = 'desecapi.tests.test_crypto'
+    context = "desecapi.tests.test_crypto"
 
 
     def test_retrieved_key_is_reproducible(self):
     def test_retrieved_key_is_reproducible(self):
-        keys = (crypto.retrieve_key(label='test', context=self.context) for _ in range(2))
+        keys = (
+            crypto.retrieve_key(label="test", context=self.context) for _ in range(2)
+        )
         self.assertEqual(*keys)
         self.assertEqual(*keys)
 
 
     def test_retrieved_key_depends_on_secret(self):
     def test_retrieved_key_depends_on_secret(self):
         keys = []
         keys = []
-        for secret in ['abcdefgh', 'hgfedcba']:
+        for secret in ["abcdefgh", "hgfedcba"]:
             with self.settings(SECRET_KEY=secret):
             with self.settings(SECRET_KEY=secret):
-                keys.append(crypto.retrieve_key(label='test', context=self.context))
+                keys.append(crypto.retrieve_key(label="test", context=self.context))
         self.assertNotEqual(*keys)
         self.assertNotEqual(*keys)
 
 
     def test_retrieved_key_depends_on_label(self):
     def test_retrieved_key_depends_on_label(self):
-        keys = (crypto.retrieve_key(label=f'test_{i}', context=self.context) for i in range(2))
+        keys = (
+            crypto.retrieve_key(label=f"test_{i}", context=self.context)
+            for i in range(2)
+        )
         self.assertNotEqual(*keys)
         self.assertNotEqual(*keys)
 
 
     def test_retrieved_key_depends_on_context(self):
     def test_retrieved_key_depends_on_context(self):
-        keys = (crypto.retrieve_key(label='test', context=f'{self.context}_{i}') for i in range(2))
+        keys = (
+            crypto.retrieve_key(label="test", context=f"{self.context}_{i}")
+            for i in range(2)
+        )
         self.assertNotEqual(*keys)
         self.assertNotEqual(*keys)
 
 
     def test_encrypt_has_high_entropy(self):
     def test_encrypt_has_high_entropy(self):
@@ -37,23 +45,25 @@ class CryptoTestCase(TestCase):
                 result -= count * log(count, 2)
                 result -= count * log(count, 2)
             return result * len(value)
             return result * len(value)
 
 
-        ciphertext = crypto.encrypt(b'test', context=self.context)
+        ciphertext = crypto.encrypt(b"test", context=self.context)
         self.assertGreater(entropy(ciphertext), 100)  # arbitrary
         self.assertGreater(entropy(ciphertext), 100)  # arbitrary
 
 
     def test_encrypt_decrypt(self):
     def test_encrypt_decrypt(self):
-        plain = b'test'
+        plain = b"test"
         ciphertext = crypto.encrypt(plain, context=self.context)
         ciphertext = crypto.encrypt(plain, context=self.context)
         timestamp, decrypted = crypto.decrypt(ciphertext, context=self.context)
         timestamp, decrypted = crypto.decrypt(ciphertext, context=self.context)
         self.assertEqual(plain, decrypted)
         self.assertEqual(plain, decrypted)
         self.assertTrue(0 <= time.time() - timestamp <= 1)
         self.assertTrue(0 <= time.time() - timestamp <= 1)
 
 
     def test_encrypt_decrypt_raises_on_tampering(self):
     def test_encrypt_decrypt_raises_on_tampering(self):
-        ciphertext = crypto.encrypt(b'test', context=self.context)
+        ciphertext = crypto.encrypt(b"test", context=self.context)
 
 
         with self.assertRaises(ValueError):
         with self.assertRaises(ValueError):
             ciphertext_decoded = ciphertext.decode()
             ciphertext_decoded = ciphertext.decode()
-            ciphertext_tampered = (ciphertext_decoded[:30] + 'TAMPERBEEF' + ciphertext_decoded[40:]).encode()
+            ciphertext_tampered = (
+                ciphertext_decoded[:30] + "TAMPERBEEF" + ciphertext_decoded[40:]
+            ).encode()
             crypto.decrypt(ciphertext_tampered, context=self.context)
             crypto.decrypt(ciphertext_tampered, context=self.context)
 
 
         with self.assertRaises(ValueError):
         with self.assertRaises(ValueError):
-            crypto.decrypt(ciphertext, context=f'{self.context}2')
+            crypto.decrypt(ciphertext, context=f"{self.context}2")

File diff suppressed because it is too large
+ 397 - 231
api/desecapi/tests/test_domains.py


+ 28 - 25
api/desecapi/tests/test_donations.py

@@ -6,18 +6,17 @@ from desecapi.tests.base import DesecTestCase
 
 
 
 
 class DonationTests(DesecTestCase):
 class DonationTests(DesecTestCase):
-
     def test_unauthorized_access(self):
     def test_unauthorized_access(self):
         for method in [self.client.get, self.client.put, self.client.delete]:
         for method in [self.client.get, self.client.put, self.client.delete]:
-            response = method(reverse('v1:donation'))
+            response = method(reverse("v1:donation"))
             self.assertStatus(response, status.HTTP_405_METHOD_NOT_ALLOWED)
             self.assertStatus(response, status.HTTP_405_METHOD_NOT_ALLOWED)
 
 
     def test_create_donation_minimal(self):
     def test_create_donation_minimal(self):
-        url = reverse('v1:donation')
+        url = reverse("v1:donation")
         data = {
         data = {
-            'name': 'Name',
-            'iban': 'DE89370400440532013000',
-            'amount': 123.45,
+            "name": "Name",
+            "iban": "DE89370400440532013000",
+            "amount": 123.45,
         }
         }
         response = self.client.post(url, data)
         response = self.client.post(url, data)
         self.assertTrue(mail.outbox)
         self.assertTrue(mail.outbox)
@@ -25,24 +24,26 @@ class DonationTests(DesecTestCase):
         direct_debit = str(mail.outbox[0].attachments[0][1])
         direct_debit = str(mail.outbox[0].attachments[0][1])
         reply_to = mail.outbox[0].reply_to
         reply_to = mail.outbox[0].reply_to
         self.assertStatus(response, status.HTTP_201_CREATED)
         self.assertStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(response.data.keys(), {'name', 'amount', 'email', 'mref', 'interval'})
+        self.assertEqual(
+            response.data.keys(), {"name", "amount", "email", "mref", "interval"}
+        )
         self.assertEqual(len(mail.outbox), 1)
         self.assertEqual(len(mail.outbox), 1)
-        self.assertEqual(response.data['interval'], 0)
-        self.assertIn('ONDON1', response.data['mref'])
-        self.assertTrue('Name' in direct_debit)
-        self.assertTrue(data['iban'] in email_internal)
+        self.assertEqual(response.data["interval"], 0)
+        self.assertIn("ONDON1", response.data["mref"])
+        self.assertTrue("Name" in direct_debit)
+        self.assertTrue(data["iban"] in email_internal)
         self.assertEqual(reply_to, [])
         self.assertEqual(reply_to, [])
 
 
     def test_create_donation_verbose(self):
     def test_create_donation_verbose(self):
-        url = reverse('v1:donation')
+        url = reverse("v1:donation")
         data = {
         data = {
-            'name': 'Komplizierter Vörnämü-ßßß 马大为',
-            'iban': 'DE89370400440532013000',
-            'bic': 'BYLADEM1SWU',
-            'amount': 123.45,
-            'message': 'hi there, thank you. Also, some random chars:  ™ • ½ ¼ ¾ ⅓ ⅔ † ‡ µ ¢ £ € « » ♤ ♧ ♥ ♢ ¿ ',
-            'email': 'email@example.com',
-            'interval': 3,
+            "name": "Komplizierter Vörnämü-ßßß 马大为",
+            "iban": "DE89370400440532013000",
+            "bic": "BYLADEM1SWU",
+            "amount": 123.45,
+            "message": "hi there, thank you. Also, some random chars:  ™ • ½ ¼ ¾ ⅓ ⅔ † ‡ µ ¢ £ € « » ♤ ♧ ♥ ♢ ¿ ",
+            "email": "email@example.com",
+            "interval": 3,
         }
         }
         response = self.client.post(url, data)
         response = self.client.post(url, data)
         self.assertTrue(mail.outbox)
         self.assertTrue(mail.outbox)
@@ -50,10 +51,12 @@ class DonationTests(DesecTestCase):
         direct_debit = str(mail.outbox[0].attachments[0][1])
         direct_debit = str(mail.outbox[0].attachments[0][1])
         reply_to = mail.outbox[0].reply_to
         reply_to = mail.outbox[0].reply_to
         self.assertStatus(response, status.HTTP_201_CREATED)
         self.assertStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(response.data.keys(), {'name', 'amount', 'email', 'mref', 'interval'})
+        self.assertEqual(
+            response.data.keys(), {"name", "amount", "email", "mref", "interval"}
+        )
         self.assertEqual(len(mail.outbox), 2)
         self.assertEqual(len(mail.outbox), 2)
-        self.assertEqual(response.data['interval'], 3)
-        self.assertIn('ONDON1', response.data['mref'])
-        self.assertTrue('Komplizierter Vornamu' in direct_debit)
-        self.assertTrue(data['iban'] in email_internal)
-        self.assertEqual(reply_to, [data['email']])
+        self.assertEqual(response.data["interval"], 3)
+        self.assertIn("ONDON1", response.data["mref"])
+        self.assertTrue("Komplizierter Vornamu" in direct_debit)
+        self.assertTrue(data["iban"] in email_internal)
+        self.assertEqual(reply_to, [data["email"]])

+ 112 - 91
api/desecapi/tests/test_dyndns12update.py

@@ -6,45 +6,52 @@ from desecapi.tests.base import DynDomainOwnerTestCase
 
 
 
 
 class DynDNS12UpdateTest(DynDomainOwnerTestCase):
 class DynDNS12UpdateTest(DynDomainOwnerTestCase):
-
-    def assertIP(self, ipv4=None, ipv6=None, name=None, subname=''):
+    def assertIP(self, ipv4=None, ipv6=None, name=None, subname=""):
         name = name or self.my_domain.name.lower()
         name = name or self.my_domain.name.lower()
-        for type_, value in [('A', ipv4), ('AAAA', ipv6)]:
-            url = self.reverse('v1:rrset', name=name, subname=subname, type=type_)
+        for type_, value in [("A", ipv4), ("AAAA", ipv6)]:
+            url = self.reverse("v1:rrset", name=name, subname=subname, type=type_)
             response = self.client_token_authorized.get(url)
             response = self.client_token_authorized.get(url)
             if value:
             if value:
                 self.assertStatus(response, status.HTTP_200_OK)
                 self.assertStatus(response, status.HTTP_200_OK)
-                self.assertEqual(response.data['records'][0], value)
-                self.assertEqual(response.data['ttl'], 60)
+                self.assertEqual(response.data["records"][0], value)
+                self.assertEqual(response.data["ttl"], 60)
             else:
             else:
                 self.assertStatus(response, status.HTTP_404_NOT_FOUND)
                 self.assertStatus(response, status.HTTP_404_NOT_FOUND)
 
 
     def test_identification_by_domain_name(self):
     def test_identification_by_domain_name(self):
-        self.client.set_credentials_basic_auth(self.my_domain.name + '.invalid', self.token.plain)
-        response = self.assertDynDNS12NoUpdate(mock_remote_addr='10.5.5.6')
+        self.client.set_credentials_basic_auth(
+            self.my_domain.name + ".invalid", self.token.plain
+        )
+        response = self.assertDynDNS12NoUpdate(mock_remote_addr="10.5.5.6")
         self.assertStatus(response, status.HTTP_401_UNAUTHORIZED)
         self.assertStatus(response, status.HTTP_401_UNAUTHORIZED)
 
 
     def test_identification_by_query_params(self):
     def test_identification_by_query_params(self):
         # /update?username=foobar.dedyn.io&password=secret
         # /update?username=foobar.dedyn.io&password=secret
         self.client.set_credentials_basic_auth(None, None)
         self.client.set_credentials_basic_auth(None, None)
-        response = self.assertDynDNS12Update(username=self.my_domain.name, password=self.token.plain)
+        response = self.assertDynDNS12Update(
+            username=self.my_domain.name, password=self.token.plain
+        )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
-        self.assertEqual(response.data, 'good')
-        self.assertEqual(response.content_type, 'text/plain')
-        self.assertIP(ipv4='127.0.0.1')
+        self.assertEqual(response.data, "good")
+        self.assertEqual(response.content_type, "text/plain")
+        self.assertIP(ipv4="127.0.0.1")
 
 
     def test_identification_by_query_params_with_subdomain(self):
     def test_identification_by_query_params_with_subdomain(self):
         # /update?username=baz.foobar.dedyn.io&password=secret
         # /update?username=baz.foobar.dedyn.io&password=secret
         self.client.set_credentials_basic_auth(None, None)
         self.client.set_credentials_basic_auth(None, None)
-        response = self.assertDynDNS12NoUpdate(username='baz', password=self.token.plain)
+        response = self.assertDynDNS12NoUpdate(
+            username="baz", password=self.token.plain
+        )
         self.assertStatus(response, status.HTTP_401_UNAUTHORIZED)
         self.assertStatus(response, status.HTTP_401_UNAUTHORIZED)
-        self.assertEqual(response.content, b'badauth')
+        self.assertEqual(response.content, b"badauth")
 
 
-        for subname in ['baz', '*.baz']:
-            response = self.assertDynDNS12Update(username=f'{subname}.{self.my_domain.name}', password=self.token.plain)
+        for subname in ["baz", "*.baz"]:
+            response = self.assertDynDNS12Update(
+                username=f"{subname}.{self.my_domain.name}", password=self.token.plain
+            )
             self.assertStatus(response, status.HTTP_200_OK)
             self.assertStatus(response, status.HTTP_200_OK)
-            self.assertEqual(response.data, 'good')
-            self.assertIP(ipv4='127.0.0.1', subname=subname)
+            self.assertEqual(response.data, "good")
+            self.assertIP(ipv4="127.0.0.1", subname=subname)
 
 
     def test_deviant_ttl(self):
     def test_deviant_ttl(self):
         """
         """
@@ -56,44 +63,46 @@ class DynDNS12UpdateTest(DynDomainOwnerTestCase):
             self.request_pdns_zone_update(self.my_domain.name),
             self.request_pdns_zone_update(self.my_domain.name),
             self.request_pdns_zone_axfr(self.my_domain.name),
             self.request_pdns_zone_axfr(self.my_domain.name),
         ):
         ):
-            response = self.client_token_authorized.patch_rr_set(self.my_domain.name.lower(), '', 'A', {'ttl': 3600})
+            response = self.client_token_authorized.patch_rr_set(
+                self.my_domain.name.lower(), "", "A", {"ttl": 3600}
+            )
             self.assertStatus(response, status.HTTP_200_OK)
             self.assertStatus(response, status.HTTP_200_OK)
 
 
         response = self.assertDynDNS12Update(self.my_domain.name)
         response = self.assertDynDNS12Update(self.my_domain.name)
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
-        self.assertEqual(response.data, 'good')
-        self.assertIP(ipv4='127.0.0.1')
+        self.assertEqual(response.data, "good")
+        self.assertIP(ipv4="127.0.0.1")
 
 
     def test_ddclient_dyndns1_v4_success(self):
     def test_ddclient_dyndns1_v4_success(self):
         # /nic/dyndns?action=edit&started=1&hostname=YES&host_id=foobar.dedyn.io&myip=10.1.2.3
         # /nic/dyndns?action=edit&started=1&hostname=YES&host_id=foobar.dedyn.io&myip=10.1.2.3
         with self.assertPdnsRequests(
         with self.assertPdnsRequests(
-                self.request_pdns_zone_update(self.my_domain.name),
-                self.request_pdns_zone_axfr(self.my_domain.name),
+            self.request_pdns_zone_update(self.my_domain.name),
+            self.request_pdns_zone_axfr(self.my_domain.name),
         ):
         ):
             response = self.client.get(
             response = self.client.get(
-                self.reverse('v1:dyndns12update'),
+                self.reverse("v1:dyndns12update"),
                 {
                 {
-                    'action': 'edit',
-                    'started': 1,
-                    'hostname': 'YES',
-                    'host_id': self.my_domain.name,
-                    'myip': '10.1.2.3'
-                }
+                    "action": "edit",
+                    "started": 1,
+                    "hostname": "YES",
+                    "host_id": self.my_domain.name,
+                    "myip": "10.1.2.3",
+                },
             )
             )
             self.assertStatus(response, status.HTTP_200_OK)
             self.assertStatus(response, status.HTTP_200_OK)
-            self.assertEqual(response.data, 'good')
-            self.assertIP(ipv4='10.1.2.3')
+            self.assertEqual(response.data, "good")
+            self.assertIP(ipv4="10.1.2.3")
 
 
         # Repeat and make sure that no pdns request is made (not even for the empty AAAA record)
         # Repeat and make sure that no pdns request is made (not even for the empty AAAA record)
         response = self.client.get(
         response = self.client.get(
-            self.reverse('v1:dyndns12update'),
+            self.reverse("v1:dyndns12update"),
             {
             {
-                'action': 'edit',
-                'started': 1,
-                'hostname': 'YES',
-                'host_id': self.my_domain.name,
-                'myip': '10.1.2.3'
-            }
+                "action": "edit",
+                "started": 1,
+                "hostname": "YES",
+                "host_id": self.my_domain.name,
+                "myip": "10.1.2.3",
+            },
         )
         )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
 
 
@@ -101,27 +110,27 @@ class DynDNS12UpdateTest(DynDomainOwnerTestCase):
         # /nic/dyndns?action=edit&started=1&hostname=YES&host_id=foobar.dedyn.io&myipv6=::1337
         # /nic/dyndns?action=edit&started=1&hostname=YES&host_id=foobar.dedyn.io&myipv6=::1337
         response = self.assertDynDNS12Update(
         response = self.assertDynDNS12Update(
             domain_name=self.my_domain.name,
             domain_name=self.my_domain.name,
-            action='edit',
+            action="edit",
             started=1,
             started=1,
-            hostname='YES',
+            hostname="YES",
             host_id=self.my_domain.name,
             host_id=self.my_domain.name,
-            myipv6='::1337'
+            myipv6="::1337",
         )
         )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
-        self.assertEqual(response.data, 'good')
-        self.assertIP(ipv4='127.0.0.1', ipv6='::1337')
+        self.assertEqual(response.data, "good")
+        self.assertIP(ipv4="127.0.0.1", ipv6="::1337")
 
 
         # Repeat and make sure that no pdns request is made (not even for the empty A record)
         # Repeat and make sure that no pdns request is made (not even for the empty A record)
         response = self.client.get(
         response = self.client.get(
-            self.reverse('v1:dyndns12update'),
+            self.reverse("v1:dyndns12update"),
             {
             {
-                'domain_name': self.my_domain.name,
-                'action': 'edit',
-                'started': 1,
-                'hostname': 'YES',
-                'host_id': self.my_domain.name,
-                'myipv6': '::1337'
-        }
+                "domain_name": self.my_domain.name,
+                "action": "edit",
+                "started": 1,
+                "hostname": "YES",
+                "host_id": self.my_domain.name,
+                "myipv6": "::1337",
+            },
         )
         )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
 
 
@@ -129,66 +138,70 @@ class DynDNS12UpdateTest(DynDomainOwnerTestCase):
         # /nic/update?system=dyndns&hostname=foobar.dedyn.io&myip=10.2.3.4
         # /nic/update?system=dyndns&hostname=foobar.dedyn.io&myip=10.2.3.4
         response = self.assertDynDNS12Update(
         response = self.assertDynDNS12Update(
             domain_name=self.my_domain.name,
             domain_name=self.my_domain.name,
-            system='dyndns',
+            system="dyndns",
             hostname=self.my_domain.name,
             hostname=self.my_domain.name,
-            myip='10.2.3.4',
+            myip="10.2.3.4",
         )
         )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
-        self.assertEqual(response.data, 'good')
-        self.assertIP(ipv4='10.2.3.4')
+        self.assertEqual(response.data, "good")
+        self.assertIP(ipv4="10.2.3.4")
 
 
     def test_ddclient_dyndns2_v4_invalid(self):
     def test_ddclient_dyndns2_v4_invalid(self):
         # /nic/update?system=dyndns&hostname=foobar.dedyn.io&myip=10.2.3.4asdf
         # /nic/update?system=dyndns&hostname=foobar.dedyn.io&myip=10.2.3.4asdf
         params = {
         params = {
-            'domain_name': self.my_domain.name,
-            'system': 'dyndns',
-            'hostname': self.my_domain.name,
-            'myip': '10.2.3.4asdf',
+            "domain_name": self.my_domain.name,
+            "system": "dyndns",
+            "hostname": self.my_domain.name,
+            "myip": "10.2.3.4asdf",
         }
         }
-        response = self.client.get(self.reverse('v1:dyndns12update'), params)
+        response = self.client.get(self.reverse("v1:dyndns12update"), params)
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-        self.assertIn('malformed', str(response.data))
+        self.assertIn("malformed", str(response.data))
 
 
     def test_ddclient_dyndns2_v4_invalid_or_foreign_domain(self):
     def test_ddclient_dyndns2_v4_invalid_or_foreign_domain(self):
         # /nic/update?system=dyndns&hostname=<...>&myip=10.2.3.4
         # /nic/update?system=dyndns&hostname=<...>&myip=10.2.3.4
-        for name in [self.owner.email, self.other_domain.name, self.my_domain.parent_domain_name]:
+        for name in [
+            self.owner.email,
+            self.other_domain.name,
+            self.my_domain.parent_domain_name,
+        ]:
             response = self.assertDynDNS12NoUpdate(
             response = self.assertDynDNS12NoUpdate(
-                system='dyndns',
+                system="dyndns",
                 hostname=name,
                 hostname=name,
-                myip='10.2.3.4',
+                myip="10.2.3.4",
             )
             )
             self.assertStatus(response, status.HTTP_404_NOT_FOUND)
             self.assertStatus(response, status.HTTP_404_NOT_FOUND)
-            self.assertEqual(response.content, b'nohost')
+            self.assertEqual(response.content, b"nohost")
 
 
     def test_ddclient_dyndns2_v6_success(self):
     def test_ddclient_dyndns2_v6_success(self):
         # /nic/update?system=dyndns&hostname=foobar.dedyn.io&myipv6=::1338
         # /nic/update?system=dyndns&hostname=foobar.dedyn.io&myipv6=::1338
         response = self.assertDynDNS12Update(
         response = self.assertDynDNS12Update(
             domain_name=self.my_domain.name,
             domain_name=self.my_domain.name,
-            system='dyndns',
+            system="dyndns",
             hostname=self.my_domain.name,
             hostname=self.my_domain.name,
-            myipv6='::666',
+            myipv6="::666",
         )
         )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
-        self.assertEqual(response.data, 'good')
-        self.assertIP(ipv4='127.0.0.1', ipv6='::666')
+        self.assertEqual(response.data, "good")
+        self.assertIP(ipv4="127.0.0.1", ipv6="::666")
 
 
     def test_fritz_box(self):
     def test_fritz_box(self):
         # /
         # /
         response = self.assertDynDNS12Update(self.my_domain.name)
         response = self.assertDynDNS12Update(self.my_domain.name)
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
-        self.assertEqual(response.data, 'good')
-        self.assertIP(ipv4='127.0.0.1')
+        self.assertEqual(response.data, "good")
+        self.assertIP(ipv4="127.0.0.1")
 
 
     def test_unset_ip(self):
     def test_unset_ip(self):
         for (v4, v6) in [
         for (v4, v6) in [
-            ('127.0.0.1', '::1'),
-            ('127.0.0.1', ''),
-            ('', '::1'),
-            ('', ''),
+            ("127.0.0.1", "::1"),
+            ("127.0.0.1", ""),
+            ("", "::1"),
+            ("", ""),
         ]:
         ]:
             response = self.assertDynDNS12Update(self.my_domain.name, ip=v4, ipv6=v6)
             response = self.assertDynDNS12Update(self.my_domain.name, ip=v4, ipv6=v6)
             self.assertStatus(response, status.HTTP_200_OK)
             self.assertStatus(response, status.HTTP_200_OK)
-            self.assertEqual(response.data, 'good')
+            self.assertEqual(response.data, "good")
             self.assertIP(ipv4=v4, ipv6=v6)
             self.assertIP(ipv4=v4, ipv6=v6)
 
 
 
 
@@ -196,18 +209,22 @@ class SingleDomainDynDNS12UpdateTest(DynDNS12UpdateTest):
     NUM_OWNED_DOMAINS = 1
     NUM_OWNED_DOMAINS = 1
 
 
     def test_identification_by_token(self):
     def test_identification_by_token(self):
-        self.client.set_credentials_basic_auth('', self.token.plain)
-        response = self.assertDynDNS12Update(self.my_domain.name, mock_remote_addr='10.5.5.6')
+        self.client.set_credentials_basic_auth("", self.token.plain)
+        response = self.assertDynDNS12Update(
+            self.my_domain.name, mock_remote_addr="10.5.5.6"
+        )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
-        self.assertEqual(response.data, 'good')
-        self.assertIP(ipv4='10.5.5.6')
+        self.assertEqual(response.data, "good")
+        self.assertIP(ipv4="10.5.5.6")
 
 
     def test_identification_by_email(self):
     def test_identification_by_email(self):
         self.client.set_credentials_basic_auth(self.owner.email, self.token.plain)
         self.client.set_credentials_basic_auth(self.owner.email, self.token.plain)
-        response = self.assertDynDNS12Update(self.my_domain.name, mock_remote_addr='10.5.5.6')
+        response = self.assertDynDNS12Update(
+            self.my_domain.name, mock_remote_addr="10.5.5.6"
+        )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
-        self.assertEqual(response.data, 'good')
-        self.assertIP(ipv4='10.5.5.6')
+        self.assertEqual(response.data, "good")
+        self.assertIP(ipv4="10.5.5.6")
 
 
 
 
 class MultipleDomainDynDNS12UpdateTest(DynDNS12UpdateTest):
 class MultipleDomainDynDNS12UpdateTest(DynDNS12UpdateTest):
@@ -220,25 +237,30 @@ class MultipleDomainDynDNS12UpdateTest(DynDNS12UpdateTest):
         # Test that dynDNS updates work both under a local public suffix (self.my_domain) and for a custom domains
         # Test that dynDNS updates work both under a local public suffix (self.my_domain) and for a custom domains
         for domain in [self.my_domain, self.create_domain(owner=self.owner)]:
         for domain in [self.my_domain, self.create_domain(owner=self.owner)]:
             self.assertGreater(domain.minimum_ttl, 60)
             self.assertGreater(domain.minimum_ttl, 60)
-            self.client.set_credentials_basic_auth(domain.name.lower(), self.token.plain)
+            self.client.set_credentials_basic_auth(
+                domain.name.lower(), self.token.plain
+            )
             response = self.assertDynDNS12Update(domain.name)
             response = self.assertDynDNS12Update(domain.name)
             self.assertStatus(response, status.HTTP_200_OK)
             self.assertStatus(response, status.HTTP_200_OK)
-            self.assertEqual(domain.rrset_set.get(subname='', type='A').ttl, 60)
+            self.assertEqual(domain.rrset_set.get(subname="", type="A").ttl, 60)
 
 
     def test_identification_by_token(self):
     def test_identification_by_token(self):
         """
         """
         Test if the conflict of having multiple domains, but not specifying which to update is correctly recognized.
         Test if the conflict of having multiple domains, but not specifying which to update is correctly recognized.
         """
         """
-        self.client.set_credentials_basic_auth('', self.token.plain)
-        response = self.client.get(self.reverse('v1:dyndns12update'), REMOTE_ADDR='10.5.5.7')
+        self.client.set_credentials_basic_auth("", self.token.plain)
+        response = self.client.get(
+            self.reverse("v1:dyndns12update"), REMOTE_ADDR="10.5.5.7"
+        )
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
 
 
 
 
 class MixedCaseDynDNS12UpdateTestCase(DynDNS12UpdateTest):
 class MixedCaseDynDNS12UpdateTestCase(DynDNS12UpdateTest):
-
     @staticmethod
     @staticmethod
     def random_casing(s):
     def random_casing(s):
-        return ''.join([c.lower() if random.choice([True, False]) else c.upper() for c in s])
+        return "".join(
+            [c.lower() if random.choice([True, False]) else c.upper() for c in s]
+        )
 
 
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
@@ -246,7 +268,6 @@ class MixedCaseDynDNS12UpdateTestCase(DynDNS12UpdateTest):
 
 
 
 
 class UppercaseDynDNS12UpdateTestCase(DynDNS12UpdateTest):
 class UppercaseDynDNS12UpdateTestCase(DynDNS12UpdateTest):
-
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
         self.my_domain.name = self.my_domain.name.upper()
         self.my_domain.name = self.my_domain.name.upper()

+ 16 - 8
api/desecapi/tests/test_limit.py

@@ -6,29 +6,37 @@ from desecapi.tests.base import DomainOwnerTestCase
 
 
 
 
 class LimitCommandTest(DomainOwnerTestCase):
 class LimitCommandTest(DomainOwnerTestCase):
-
     def test_update_domains(self):
     def test_update_domains(self):
-        management.call_command('limit', 'domains', self.owner.email, '123')
+        management.call_command("limit", "domains", self.owner.email, "123")
         self.owner.refresh_from_db()
         self.owner.refresh_from_db()
         self.assertEqual(self.owner.limit_domains, 123)
         self.assertEqual(self.owner.limit_domains, 123)
-        management.call_command('limit', 'domains', self.owner.email, 567)
+        management.call_command("limit", "domains", self.owner.email, 567)
         self.owner.refresh_from_db()
         self.owner.refresh_from_db()
         self.assertEqual(self.owner.limit_domains, 567)
         self.assertEqual(self.owner.limit_domains, 567)
-        management.call_command('limit', 'domains', self.owner.email, '1')  # below the actual number of domains
+        management.call_command(
+            "limit", "domains", self.owner.email, "1"
+        )  # below the actual number of domains
         self.owner.refresh_from_db()
         self.owner.refresh_from_db()
         self.assertEqual(self.owner.limit_domains, 1)
         self.assertEqual(self.owner.limit_domains, 1)
         # did not delete domains below limit:
         # did not delete domains below limit:
         self.assertEqual(Domain.objects.filter(owner_id=self.owner.id).count(), 2)
         self.assertEqual(Domain.objects.filter(owner_id=self.owner.id).count(), 2)
 
 
     def test_update_minimum_ttl(self):
     def test_update_minimum_ttl(self):
-        management.call_command('limit', 'ttl', self.my_domain.name, '123')
+        management.call_command("limit", "ttl", self.my_domain.name, "123")
         self.my_domain.refresh_from_db()
         self.my_domain.refresh_from_db()
         self.assertEqual(self.my_domain.minimum_ttl, 123)
         self.assertEqual(self.my_domain.minimum_ttl, 123)
-        management.call_command('limit', 'ttl', self.my_domain.name, 567)
+        management.call_command("limit", "ttl", self.my_domain.name, 567)
         self.my_domain.refresh_from_db()
         self.my_domain.refresh_from_db()
         self.assertEqual(self.my_domain.minimum_ttl, 567)
         self.assertEqual(self.my_domain.minimum_ttl, 567)
-        management.call_command('limit', 'ttl', self.my_domain.name, '10000')  # above the currently used ttl
+        management.call_command(
+            "limit", "ttl", self.my_domain.name, "10000"
+        )  # above the currently used ttl
         self.my_domain.refresh_from_db()
         self.my_domain.refresh_from_db()
         self.assertEqual(self.my_domain.minimum_ttl, 10000)
         self.assertEqual(self.my_domain.minimum_ttl, 10000)
         # did not change existing TTLs in violation of minimum TTL:
         # did not change existing TTLs in violation of minimum TTL:
-        self.assertLess(RRset.objects.filter(domain_id=self.my_domain.id).aggregate(Min('ttl'))['ttl__min'], 10000)
+        self.assertLess(
+            RRset.objects.filter(domain_id=self.my_domain.id).aggregate(Min("ttl"))[
+                "ttl__min"
+            ],
+            10000,
+        )

+ 25 - 11
api/desecapi/tests/test_mail_backends.py

@@ -8,23 +8,37 @@ from django.test import TestCase
 from desecapi import mail_backends
 from desecapi import mail_backends
 
 
 
 
-@mock.patch.dict(mail_backends.TASKS,
-                 {key: type('obj', (object,), {'delay': mail_backends.MultiLaneEmailBackend._run_task})
-                  for key in mail_backends.TASKS})
+@mock.patch.dict(
+    mail_backends.TASKS,
+    {
+        key: type(
+            "obj", (object,), {"delay": mail_backends.MultiLaneEmailBackend._run_task}
+        )
+        for key in mail_backends.TASKS
+    },
+)
 class MultiLaneEmailBackendTestCase(TestCase):
 class MultiLaneEmailBackendTestCase(TestCase):
     test_backend = settings.EMAIL_BACKEND
     test_backend = settings.EMAIL_BACKEND
 
 
     def test_lanes(self):
     def test_lanes(self):
-        debug_params = {'foo': 'bar'}
+        debug_params = {"foo": "bar"}
         debug_params_orig = debug_params.copy()
         debug_params_orig = debug_params.copy()
 
 
-        with self.settings(EMAIL_BACKEND='desecapi.mail_backends.MultiLaneEmailBackend'):
-            for lane in ['email_slow_lane', 'email_fast_lane', None]:
-                subject = f'Test subject for lane {lane}'
-                connection = get_connection(lane=lane, backbackend=self.test_backend, debug=debug_params)
-                EmailMessage(subject=subject, to=['to@test.invalid'], connection=connection).send()
-                self.assertEqual(mail.outbox[-1].connection.task_kwargs['debug'],
-                                 {'lane': lane or 'email_slow_lane', **debug_params})
+        with self.settings(
+            EMAIL_BACKEND="desecapi.mail_backends.MultiLaneEmailBackend"
+        ):
+            for lane in ["email_slow_lane", "email_fast_lane", None]:
+                subject = f"Test subject for lane {lane}"
+                connection = get_connection(
+                    lane=lane, backbackend=self.test_backend, debug=debug_params
+                )
+                EmailMessage(
+                    subject=subject, to=["to@test.invalid"], connection=connection
+                ).send()
+                self.assertEqual(
+                    mail.outbox[-1].connection.task_kwargs["debug"],
+                    {"lane": lane or "email_slow_lane", **debug_params},
+                )
                 self.assertEqual(mail.outbox[-1].subject, subject)
                 self.assertEqual(mail.outbox[-1].subject, subject)
 
 
         # Check that the backend hasn't modified the dict we passed
         # Check that the backend hasn't modified the dict we passed

+ 245 - 125
api/desecapi/tests/test_pdns_change_tracker.py

@@ -14,9 +14,15 @@ class PdnsChangeTrackerTestCase(DesecTestCase):
     @classmethod
     @classmethod
     def setUpTestDataWithPdns(cls):
     def setUpTestDataWithPdns(cls):
         super().setUpTestDataWithPdns()
         super().setUpTestDataWithPdns()
-        cls.empty_domain = Domain.objects.create(owner=cls.user, name=cls.random_domain_name())
-        cls.simple_domain = Domain.objects.create(owner=cls.user, name=cls.random_domain_name())
-        cls.full_domain = Domain.objects.create(owner=cls.user, name=cls.random_domain_name())
+        cls.empty_domain = Domain.objects.create(
+            owner=cls.user, name=cls.random_domain_name()
+        )
+        cls.simple_domain = Domain.objects.create(
+            owner=cls.user, name=cls.random_domain_name()
+        )
+        cls.full_domain = Domain.objects.create(
+            owner=cls.user, name=cls.random_domain_name()
+        )
 
 
     def assertPdnsZoneUpdate(self, name, rr_sets):
     def assertPdnsZoneUpdate(self, name, rr_sets):
         return self.assertPdnsRequests(
         return self.assertPdnsRequests(
@@ -29,7 +35,7 @@ class PdnsChangeTrackerTestCase(DesecTestCase):
     def test_rrset_does_not_exist_exception(self):
     def test_rrset_does_not_exist_exception(self):
         tracker = PDNSChangeTracker()
         tracker = PDNSChangeTracker()
         tracker.__enter__()
         tracker.__enter__()
-        tracker._rr_set_updated(RRset(domain=self.empty_domain, subname='', type='A'))
+        tracker._rr_set_updated(RRset(domain=self.empty_domain, subname="", type="A"))
         with self.assertRaises(ValueError):
         with self.assertRaises(ValueError):
             tracker.__exit__(None, None, None)
             tracker.__exit__(None, None, None)
 
 
@@ -38,13 +44,14 @@ class RRTestCase(PdnsChangeTrackerTestCase):
     """
     """
     Base-class for checking change tracker behavior for all create, update, and delete operations of the RR model.
     Base-class for checking change tracker behavior for all create, update, and delete operations of the RR model.
     """
     """
+
     NUM_OWNED_DOMAINS = 3
     NUM_OWNED_DOMAINS = 3
 
 
-    SUBNAME = 'my_rr_set'
-    TYPE = 'A'
+    SUBNAME = "my_rr_set"
+    TYPE = "A"
     TTL = 334
     TTL = 334
-    CONTENT_VALUES = ['2.130.250.238', '170.95.95.252', '128.238.1.5']
-    ALT_CONTENT_VALUES = ['190.169.34.46', '216.228.24.25', '151.138.61.173']
+    CONTENT_VALUES = ["2.130.250.238", "170.95.95.252", "128.238.1.5"]
+    ALT_CONTENT_VALUES = ["190.169.34.46", "216.228.24.25", "151.138.61.173"]
 
 
     @classmethod
     @classmethod
     def setUpTestDataWithPdns(cls):
     def setUpTestDataWithPdns(cls):
@@ -52,7 +59,9 @@ class RRTestCase(PdnsChangeTrackerTestCase):
 
 
         rr_set_data = dict(subname=cls.SUBNAME, type=cls.TYPE, ttl=cls.TTL)
         rr_set_data = dict(subname=cls.SUBNAME, type=cls.TYPE, ttl=cls.TTL)
         cls.empty_rr_set = RRset.objects.create(domain=cls.empty_domain, **rr_set_data)
         cls.empty_rr_set = RRset.objects.create(domain=cls.empty_domain, **rr_set_data)
-        cls.simple_rr_set = RRset.objects.create(domain=cls.simple_domain, **rr_set_data)
+        cls.simple_rr_set = RRset.objects.create(
+            domain=cls.simple_domain, **rr_set_data
+        )
         cls.full_rr_set = RRset.objects.create(domain=cls.full_domain, **rr_set_data)
         cls.full_rr_set = RRset.objects.create(domain=cls.full_domain, **rr_set_data)
 
 
         RR.objects.create(rrset=cls.simple_rr_set, content=cls.CONTENT_VALUES[0])
         RR.objects.create(rrset=cls.simple_rr_set, content=cls.CONTENT_VALUES[0])
@@ -130,20 +139,30 @@ class RRTestCase(PdnsChangeTrackerTestCase):
 
 
     def test_create_delete_empty_rr_set(self):
     def test_create_delete_empty_rr_set(self):
         with self.assertPdnsEmptyRRSetUpdate(), PDNSChangeTracker():
         with self.assertPdnsEmptyRRSetUpdate(), PDNSChangeTracker():
-            new_rr = RR.objects.create(rrset=self.empty_rr_set, content=self.ALT_CONTENT_VALUES[0])
-            RR.objects.create(rrset=self.empty_rr_set, content=self.ALT_CONTENT_VALUES[1])
+            new_rr = RR.objects.create(
+                rrset=self.empty_rr_set, content=self.ALT_CONTENT_VALUES[0]
+            )
+            RR.objects.create(
+                rrset=self.empty_rr_set, content=self.ALT_CONTENT_VALUES[1]
+            )
             new_rr.delete()
             new_rr.delete()
 
 
     def test_create_delete_simple_rr_set_1(self):
     def test_create_delete_simple_rr_set_1(self):
         with self.assertPdnsSimpleRRSetUpdate(), PDNSChangeTracker():
         with self.assertPdnsSimpleRRSetUpdate(), PDNSChangeTracker():
-            new_rr = RR.objects.create(rrset=self.simple_rr_set, content=self.ALT_CONTENT_VALUES[0])
-            RR.objects.create(rrset=self.simple_rr_set, content=self.ALT_CONTENT_VALUES[1])
+            new_rr = RR.objects.create(
+                rrset=self.simple_rr_set, content=self.ALT_CONTENT_VALUES[0]
+            )
+            RR.objects.create(
+                rrset=self.simple_rr_set, content=self.ALT_CONTENT_VALUES[1]
+            )
             new_rr.delete()
             new_rr.delete()
 
 
     def test_create_delete_simple_rr_set_2(self):
     def test_create_delete_simple_rr_set_2(self):
         with self.assertPdnsSimpleRRSetUpdate(), PDNSChangeTracker():
         with self.assertPdnsSimpleRRSetUpdate(), PDNSChangeTracker():
             self.simple_rr_set.records.all()[0].delete()
             self.simple_rr_set.records.all()[0].delete()
-            RR.objects.create(rrset=self.simple_rr_set, content=self.ALT_CONTENT_VALUES[0])
+            RR.objects.create(
+                rrset=self.simple_rr_set, content=self.ALT_CONTENT_VALUES[0]
+            )
 
 
     def test_create_delete_simple_rr_set_3(self):
     def test_create_delete_simple_rr_set_3(self):
         with self.assertPdnsSimpleRRSetUpdate(), PDNSChangeTracker():
         with self.assertPdnsSimpleRRSetUpdate(), PDNSChangeTracker():
@@ -166,28 +185,38 @@ class RRTestCase(PdnsChangeTrackerTestCase):
 
 
     def test_create_update_empty_rr_set_1(self):
     def test_create_update_empty_rr_set_1(self):
         with self.assertPdnsEmptyRRSetUpdate(), PDNSChangeTracker():
         with self.assertPdnsEmptyRRSetUpdate(), PDNSChangeTracker():
-            rr = RR.objects.create(rrset=self.empty_rr_set, content=self.CONTENT_VALUES[0])
+            rr = RR.objects.create(
+                rrset=self.empty_rr_set, content=self.CONTENT_VALUES[0]
+            )
             rr.content = self.ALT_CONTENT_VALUES[0]
             rr.content = self.ALT_CONTENT_VALUES[0]
             rr.save()
             rr.save()
 
 
     def test_create_update_empty_rr_set_2(self):
     def test_create_update_empty_rr_set_2(self):
         with self.assertPdnsEmptyRRSetUpdate(), PDNSChangeTracker():
         with self.assertPdnsEmptyRRSetUpdate(), PDNSChangeTracker():
-            for (content, alt_content) in zip(self.CONTENT_VALUES, self.ALT_CONTENT_VALUES):
+            for (content, alt_content) in zip(
+                self.CONTENT_VALUES, self.ALT_CONTENT_VALUES
+            ):
                 rr = RR.objects.create(rrset=self.empty_rr_set, content=content)
                 rr = RR.objects.create(rrset=self.empty_rr_set, content=content)
                 rr.content = alt_content
                 rr.content = alt_content
                 rr.save()
                 rr.save()
 
 
     def test_create_update_empty_rr_set_3(self):
     def test_create_update_empty_rr_set_3(self):
         with self.assertPdnsEmptyRRSetUpdate(), PDNSChangeTracker():
         with self.assertPdnsEmptyRRSetUpdate(), PDNSChangeTracker():
-            rr = RR.objects.create(rrset=self.empty_rr_set, content=self.ALT_CONTENT_VALUES[0])
-            RR.objects.create(rrset=self.empty_rr_set, content=self.ALT_CONTENT_VALUES[1])
+            rr = RR.objects.create(
+                rrset=self.empty_rr_set, content=self.ALT_CONTENT_VALUES[0]
+            )
+            RR.objects.create(
+                rrset=self.empty_rr_set, content=self.ALT_CONTENT_VALUES[1]
+            )
             rr.content = self.CONTENT_VALUES[0]
             rr.content = self.CONTENT_VALUES[0]
             rr.save()
             rr.save()
 
 
     def test_create_update_simple_rr_set(self):
     def test_create_update_simple_rr_set(self):
         with self.assertPdnsSimpleRRSetUpdate(), PDNSChangeTracker():
         with self.assertPdnsSimpleRRSetUpdate(), PDNSChangeTracker():
             rr = self.simple_rr_set.records.all()[0]
             rr = self.simple_rr_set.records.all()[0]
-            RR.objects.create(rrset=self.simple_rr_set, content=self.ALT_CONTENT_VALUES[0])
+            RR.objects.create(
+                rrset=self.simple_rr_set, content=self.ALT_CONTENT_VALUES[0]
+            )
             rr.content = self.ALT_CONTENT_VALUES[1]
             rr.content = self.ALT_CONTENT_VALUES[1]
             rr.save()
             rr.save()
 
 
@@ -225,7 +254,9 @@ class RRTestCase(PdnsChangeTrackerTestCase):
     def test_create_update_delete_empty_rr_set_2(self):
     def test_create_update_delete_empty_rr_set_2(self):
         with self.assertPdnsEmptyRRSetUpdate(), PDNSChangeTracker():
         with self.assertPdnsEmptyRRSetUpdate(), PDNSChangeTracker():
             RR.objects.create(rrset=self.empty_rr_set, content=self.CONTENT_VALUES[0])
             RR.objects.create(rrset=self.empty_rr_set, content=self.CONTENT_VALUES[0])
-            rr = RR.objects.create(rrset=self.empty_rr_set, content=self.CONTENT_VALUES[1])
+            rr = RR.objects.create(
+                rrset=self.empty_rr_set, content=self.CONTENT_VALUES[1]
+            )
             rr.content = self.ALT_CONTENT_VALUES[1]
             rr.content = self.ALT_CONTENT_VALUES[1]
             rr.save()
             rr.save()
             RR.objects.create(rrset=self.empty_rr_set, content=self.CONTENT_VALUES[2])
             RR.objects.create(rrset=self.empty_rr_set, content=self.CONTENT_VALUES[2])
@@ -235,7 +266,9 @@ class RRTestCase(PdnsChangeTrackerTestCase):
         with self.assertPdnsSimpleRRSetUpdate(), PDNSChangeTracker():
         with self.assertPdnsSimpleRRSetUpdate(), PDNSChangeTracker():
             self.simple_rr_set.records.all()[0].delete()
             self.simple_rr_set.records.all()[0].delete()
             RR.objects.create(rrset=self.simple_rr_set, content=self.CONTENT_VALUES[0])
             RR.objects.create(rrset=self.simple_rr_set, content=self.CONTENT_VALUES[0])
-            rr = RR.objects.create(rrset=self.simple_rr_set, content=self.CONTENT_VALUES[1])
+            rr = RR.objects.create(
+                rrset=self.simple_rr_set, content=self.CONTENT_VALUES[1]
+            )
             rr.content = self.ALT_CONTENT_VALUES[1]
             rr.content = self.ALT_CONTENT_VALUES[1]
             rr.save()
             rr.save()
 
 
@@ -245,51 +278,83 @@ class RRTestCase(PdnsChangeTrackerTestCase):
             rr = self.full_rr_set.records.all()[1]
             rr = self.full_rr_set.records.all()[1]
             rr.content = self.ALT_CONTENT_VALUES[0]
             rr.content = self.ALT_CONTENT_VALUES[0]
             rr.save()
             rr.save()
-            RR.objects.create(rrset=self.full_rr_set, content=self.ALT_CONTENT_VALUES[1])
+            RR.objects.create(
+                rrset=self.full_rr_set, content=self.ALT_CONTENT_VALUES[1]
+            )
 
 
 
 
 class AAAARRTestCase(RRTestCase):
 class AAAARRTestCase(RRTestCase):
-    SUBNAME = '*.foobar'
-    TYPE = 'AAAA'
+    SUBNAME = "*.foobar"
+    TYPE = "AAAA"
     TTL = 12
     TTL = 12
-    CONTENT_VALUES = ['2001:fb24:45fd:d51:7937:b375:9cf3:5c62', '2001:ed06:5ebc:9d:87a:ce9f:1ceb:996',
-                      '2001:aa22:60e8:cec5:5650:9ff9:9a1b:b588', '2001:3ca:d710:52c2:9748:eec6:2e20:af0b',
-                      '2001:9c6e:8417:3c06:dd1c:44f1:a35f:ffad', '2001:f67a:5847:8dc0:edc3:56f3:a067:f80e',
-                      '2001:4e21:bda6:a509:e777:91c6:2dc1:394', '2001:9930:b062:c38f:99f6:ce12:bb04:f7c6',
-                      '2001:bb5e:921:b17f:7c9b:afb6:9933:cc79', '2001:a861:7139:e21e:11e4:8782:242b:e2a2',
-                      '2001:eaa:ff53:c819:93e:437c:ccc8:330c', '2001:6a88:fb92:5b43:984b:b729:393b:f173']
-    ALT_CONTENT_VALUES = ['2001:2d03:6247:3494:b92e:d4a:2827:e2d', '2001:4b37:19d6:b66e:1aa1:db0f:98b5:d065',
-                          '2001:dbf1:e401:ace2:bc99:eb22:6e12:ec81', '2001:fa92:3564:7c3f:9995:2068:58bf:2a45',
-                          '2001:4c2c:c671:9f0c:600e:4eb6:672e:48c7', '2001:5d09:a6f7:594b:afa4:318a:6eda:3ec6',
-                          '2001:f33a:407c:f4e6:f886:dce2:6d08:d8ae', '2001:43c8:378d:7d37:92eb:fb0c:26b1:4998',
-                          '2001:7293:88c5:5405:fd1:7334:bb55:be20', '2001:c4b7:ae76:a9a2:ffb5:ba30:6874:a416',
-                          '2001:175f:7880:ef82:b65a:a472:14c9:a495', '2001:8c35:1566:4f53:c26a:c54:2c9f:1463']
+    CONTENT_VALUES = [
+        "2001:fb24:45fd:d51:7937:b375:9cf3:5c62",
+        "2001:ed06:5ebc:9d:87a:ce9f:1ceb:996",
+        "2001:aa22:60e8:cec5:5650:9ff9:9a1b:b588",
+        "2001:3ca:d710:52c2:9748:eec6:2e20:af0b",
+        "2001:9c6e:8417:3c06:dd1c:44f1:a35f:ffad",
+        "2001:f67a:5847:8dc0:edc3:56f3:a067:f80e",
+        "2001:4e21:bda6:a509:e777:91c6:2dc1:394",
+        "2001:9930:b062:c38f:99f6:ce12:bb04:f7c6",
+        "2001:bb5e:921:b17f:7c9b:afb6:9933:cc79",
+        "2001:a861:7139:e21e:11e4:8782:242b:e2a2",
+        "2001:eaa:ff53:c819:93e:437c:ccc8:330c",
+        "2001:6a88:fb92:5b43:984b:b729:393b:f173",
+    ]
+    ALT_CONTENT_VALUES = [
+        "2001:2d03:6247:3494:b92e:d4a:2827:e2d",
+        "2001:4b37:19d6:b66e:1aa1:db0f:98b5:d065",
+        "2001:dbf1:e401:ace2:bc99:eb22:6e12:ec81",
+        "2001:fa92:3564:7c3f:9995:2068:58bf:2a45",
+        "2001:4c2c:c671:9f0c:600e:4eb6:672e:48c7",
+        "2001:5d09:a6f7:594b:afa4:318a:6eda:3ec6",
+        "2001:f33a:407c:f4e6:f886:dce2:6d08:d8ae",
+        "2001:43c8:378d:7d37:92eb:fb0c:26b1:4998",
+        "2001:7293:88c5:5405:fd1:7334:bb55:be20",
+        "2001:c4b7:ae76:a9a2:ffb5:ba30:6874:a416",
+        "2001:175f:7880:ef82:b65a:a472:14c9:a495",
+        "2001:8c35:1566:4f53:c26a:c54:2c9f:1463",
+    ]
 
 
 
 
 class TXTRRTestCase(RRTestCase):
 class TXTRRTestCase(RRTestCase):
-    SUBNAME = '_acme_challenge'
-    TYPE = 'TXT'
+    SUBNAME = "_acme_challenge"
+    TYPE = "TXT"
     TTL = 876
     TTL = 876
-    CONTENT_VALUES = ['"The quick brown fox jumps over the lazy dog"',
-                      '"main( ) {printf(\\"hello, world\\010\\");}"',
-                      '"“红色联合”对“四·二八兵团”总部大楼的攻击已持续了两天"']
-    ALT_CONTENT_VALUES = ['"🧥 👚 👕 👖 👔 👗 👙 👘 👠 👡 👢 👞 👟 🥾 🥿 🧦 🧤 🧣 🎩 🧢 👒 🎓 ⛑ 👑 👝 👛 👜 💼 🎒 👓 🕶 🥽 🥼 🌂 🧵"',
-                          '"v=spf1 ip4:192.0.2.0/24 ip4:198.51.100.123 a -all"',
-                          '"https://en.wikipedia.org/wiki/Domain_Name_System"']
+    CONTENT_VALUES = [
+        '"The quick brown fox jumps over the lazy dog"',
+        '"main( ) {printf(\\"hello, world\\010\\");}"',
+        '"“红色联合”对“四·二八兵团”总部大楼的攻击已持续了两天"',
+    ]
+    ALT_CONTENT_VALUES = [
+        '"🧥 👚 👕 👖 👔 👗 👙 👘 👠 👡 👢 👞 👟 🥾 🥿 🧦 🧤 🧣 🎩 🧢 👒 🎓 ⛑ 👑 👝 👛 👜 💼 🎒 👓 🕶 🥽 🥼 🌂 🧵"',
+        '"v=spf1 ip4:192.0.2.0/24 ip4:198.51.100.123 a -all"',
+        '"https://en.wikipedia.org/wiki/Domain_Name_System"',
+    ]
 
 
 
 
 class RRSetTestCase(PdnsChangeTrackerTestCase):
 class RRSetTestCase(PdnsChangeTrackerTestCase):
     TEST_DATA = {
     TEST_DATA = {
-        ('A', '_asdf', 123): ['1.2.3.4', '5.5.5.5'],
-        ('TXT', 'test', 455): ['"ASDF"', '"foobar"', '"92847"'],
-        ('A', 'foo', 1010): ['1.2.3.4', '5.5.4.5'],
-        ('AAAA', '*', 100023): ['::1', '::2', '::3', '::4'],
+        ("A", "_asdf", 123): ["1.2.3.4", "5.5.5.5"],
+        ("TXT", "test", 455): ['"ASDF"', '"foobar"', '"92847"'],
+        ("A", "foo", 1010): ["1.2.3.4", "5.5.4.5"],
+        ("AAAA", "*", 100023): ["::1", "::2", "::3", "::4"],
     }
     }
 
 
     ADDITIONAL_TEST_DATA = {
     ADDITIONAL_TEST_DATA = {
-        ('A', 'zekdi', 99): ['134.48.204.28', '151.85.162.150', '5.174.133.123', '96.37.218.195', '106.18.66.163',
-                             '51.75.149.213', '9.105.0.185', '32.198.60.88', '93.141.131.151', '6.133.10.124'],
-        ('A', 'knebq', 82): ['218.154.60.184'],
+        ("A", "zekdi", 99): [
+            "134.48.204.28",
+            "151.85.162.150",
+            "5.174.133.123",
+            "96.37.218.195",
+            "106.18.66.163",
+            "51.75.149.213",
+            "9.105.0.185",
+            "32.198.60.88",
+            "93.141.131.151",
+            "6.133.10.124",
+        ],
+        ("A", "knebq", 82): ["218.154.60.184"],
     }
     }
 
 
     @classmethod
     @classmethod
@@ -314,15 +379,17 @@ class RRSetTestCase(PdnsChangeTrackerTestCase):
 
 
     def test_empty_domain_create_single_empty(self):
     def test_empty_domain_create_single_empty(self):
         with PDNSChangeTracker():
         with PDNSChangeTracker():
-            RRset.objects.create(domain=self.empty_domain, subname='', ttl=60, type='A')
+            RRset.objects.create(domain=self.empty_domain, subname="", ttl=60, type="A")
 
 
     def test_empty_domain_create_single_meaty(self):
     def test_empty_domain_create_single_meaty(self):
-        with self.assertPdnsZoneUpdate(self.empty_domain.name, self.empty_domain.rrset_set), PDNSChangeTracker():
+        with self.assertPdnsZoneUpdate(
+            self.empty_domain.name, self.empty_domain.rrset_set
+        ), PDNSChangeTracker():
             self._create_rr_sets(self.ADDITIONAL_TEST_DATA, self.empty_domain)
             self._create_rr_sets(self.ADDITIONAL_TEST_DATA, self.empty_domain)
 
 
     def test_full_domain_create_single_empty(self):
     def test_full_domain_create_single_empty(self):
         with PDNSChangeTracker():
         with PDNSChangeTracker():
-            RRset.objects.create(domain=self.full_domain, subname='', ttl=60, type='A')
+            RRset.objects.create(domain=self.full_domain, subname="", ttl=60, type="A")
 
 
     def test_empty_domain_create_many_empty(self):
     def test_empty_domain_create_many_empty(self):
         with PDNSChangeTracker():
         with PDNSChangeTracker():
@@ -330,7 +397,9 @@ class RRSetTestCase(PdnsChangeTrackerTestCase):
             self._create_rr_sets(empty_test_data, self.empty_domain)
             self._create_rr_sets(empty_test_data, self.empty_domain)
 
 
     def test_empty_domain_create_many_meaty(self):
     def test_empty_domain_create_many_meaty(self):
-        with self.assertPdnsZoneUpdate(self.empty_domain.name, self.empty_domain.rrset_set), PDNSChangeTracker():
+        with self.assertPdnsZoneUpdate(
+            self.empty_domain.name, self.empty_domain.rrset_set
+        ), PDNSChangeTracker():
             self._create_rr_sets(self.TEST_DATA, self.empty_domain)
             self._create_rr_sets(self.TEST_DATA, self.empty_domain)
 
 
     def test_empty_domain_delete(self):
     def test_empty_domain_delete(self):
@@ -341,20 +410,29 @@ class RRSetTestCase(PdnsChangeTrackerTestCase):
 
 
     def test_full_domain_delete_single(self):
     def test_full_domain_delete_single(self):
         index = (self.rr_sets[0].type, self.rr_sets[0].subname, self.rr_sets[0].ttl)
         index = (self.rr_sets[0].type, self.rr_sets[0].subname, self.rr_sets[0].ttl)
-        with self.assertPdnsZoneUpdate(self.full_domain.name, [self.TEST_DATA[index]]), PDNSChangeTracker():
+        with self.assertPdnsZoneUpdate(
+            self.full_domain.name, [self.TEST_DATA[index]]
+        ), PDNSChangeTracker():
             self.rr_sets[0].delete()
             self.rr_sets[0].delete()
 
 
     def test_full_domain_delete_multiple(self):
     def test_full_domain_delete_multiple(self):
         data = self.TEST_DATA
         data = self.TEST_DATA
         empty_data = {key: [] for key, value in data.items()}
         empty_data = {key: [] for key, value in data.items()}
-        with self.assertPdnsZoneUpdate(self.full_domain.name, empty_data), PDNSChangeTracker():
+        with self.assertPdnsZoneUpdate(
+            self.full_domain.name, empty_data
+        ), PDNSChangeTracker():
             for type_, subname, _ in data.keys():
             for type_, subname, _ in data.keys():
                 self.full_domain.rrset_set.get(subname=subname, type=type_).delete()
                 self.full_domain.rrset_set.get(subname=subname, type=type_).delete()
 
 
     def test_update_ttl(self):
     def test_update_ttl(self):
         new_ttl = 765
         new_ttl = 765
-        data = {(type_, subname, new_ttl): records for (type_, subname, _), records in self.TEST_DATA.items()}
-        with self.assertPdnsZoneUpdate(self.full_domain.name, data), PDNSChangeTracker():
+        data = {
+            (type_, subname, new_ttl): records
+            for (type_, subname, _), records in self.TEST_DATA.items()
+        }
+        with self.assertPdnsZoneUpdate(
+            self.full_domain.name, data
+        ), PDNSChangeTracker():
             for rr_set in self.full_domain.rrset_set.all():
             for rr_set in self.full_domain.rrset_set.all():
                 rr_set.ttl = new_ttl
                 rr_set.ttl = new_ttl
                 rr_set.save()
                 rr_set.save()
@@ -362,84 +440,107 @@ class RRSetTestCase(PdnsChangeTrackerTestCase):
     def test_full_domain_create_delete(self):
     def test_full_domain_create_delete(self):
         data = self.TEST_DATA
         data = self.TEST_DATA
         empty_data = {key: [] for key, value in data.items()}
         empty_data = {key: [] for key, value in data.items()}
-        with self.assertPdnsZoneUpdate(self.full_domain.name, empty_data), PDNSChangeTracker():
+        with self.assertPdnsZoneUpdate(
+            self.full_domain.name, empty_data
+        ), PDNSChangeTracker():
             self._create_rr_sets(self.ADDITIONAL_TEST_DATA, self.full_domain)
             self._create_rr_sets(self.ADDITIONAL_TEST_DATA, self.full_domain)
             for type_, subname, _ in data.keys():
             for type_, subname, _ in data.keys():
                 self.full_domain.rrset_set.get(subname=subname, type=type_).delete()
                 self.full_domain.rrset_set.get(subname=subname, type=type_).delete()
 
 
 
 
 class CommonRRSetTestCase(RRSetTestCase):
 class CommonRRSetTestCase(RRSetTestCase):
-
     def test_mixed_operations(self):
     def test_mixed_operations(self):
-        with self.assertPdnsZoneUpdate(self.full_domain.name, self.ADDITIONAL_TEST_DATA), PDNSChangeTracker():
+        with self.assertPdnsZoneUpdate(
+            self.full_domain.name, self.ADDITIONAL_TEST_DATA
+        ), PDNSChangeTracker():
             self._create_rr_sets(self.ADDITIONAL_TEST_DATA, self.full_domain)
             self._create_rr_sets(self.ADDITIONAL_TEST_DATA, self.full_domain)
 
 
         rr_sets = [
         rr_sets = [
             RRset.objects.get(type=type_, subname=subname)
             RRset.objects.get(type=type_, subname=subname)
             for (type_, subname, _) in self.ADDITIONAL_TEST_DATA.keys()
             for (type_, subname, _) in self.ADDITIONAL_TEST_DATA.keys()
         ]
         ]
-        with self.assertPdnsZoneUpdate(self.full_domain.name, rr_sets), PDNSChangeTracker():
+        with self.assertPdnsZoneUpdate(
+            self.full_domain.name, rr_sets
+        ), PDNSChangeTracker():
             for rr_set in rr_sets:
             for rr_set in rr_sets:
                 rr_set.ttl = 1
                 rr_set.ttl = 1
                 rr_set.save()
                 rr_set.save()
 
 
         data = {}
         data = {}
-        for key in [('A', '_asdf', 123), ('AAAA', '*', 100023), ('A', 'foo', 1010)]:
+        for key in [("A", "_asdf", 123), ("AAAA", "*", 100023), ("A", "foo", 1010)]:
             data[key] = self.TEST_DATA[key].copy()
             data[key] = self.TEST_DATA[key].copy()
 
 
-        with self.assertPdnsZoneUpdate(self.full_domain.name, data), PDNSChangeTracker():
-            data[('A', '_asdf', 123)].append('9.9.9.9')
-            rr_set = RRset.objects.get(domain=self.full_domain, type='A', subname='_asdf')
-            RR(content='9.9.9.9', rrset=rr_set).save()
+        with self.assertPdnsZoneUpdate(
+            self.full_domain.name, data
+        ), PDNSChangeTracker():
+            data[("A", "_asdf", 123)].append("9.9.9.9")
+            rr_set = RRset.objects.get(
+                domain=self.full_domain, type="A", subname="_asdf"
+            )
+            RR(content="9.9.9.9", rrset=rr_set).save()
 
 
-            data[('AAAA', '*', 100023)].append('::9')
-            rr_set = RRset.objects.get(domain=self.full_domain, type='AAAA', subname='*')
-            RR(content='::9', rrset=rr_set).save()
+            data[("AAAA", "*", 100023)].append("::9")
+            rr_set = RRset.objects.get(
+                domain=self.full_domain, type="AAAA", subname="*"
+            )
+            RR(content="::9", rrset=rr_set).save()
 
 
-            data[('A', 'foo', 1010)] = []
-            RRset.objects.get(domain=self.full_domain, type='A', subname='foo').delete()
+            data[("A", "foo", 1010)] = []
+            RRset.objects.get(domain=self.full_domain, type="A", subname="foo").delete()
 
 
 
 
 class UncommonRRSetTestCase(RRSetTestCase):
 class UncommonRRSetTestCase(RRSetTestCase):
     TEST_DATA = {
     TEST_DATA = {
-        ('SPF', 'baz', 444): ['"v=spf1 ip4:192.0.2.0/24 ip4:198.51.100.123 a -all"',
-                              '"v=spf1 a mx ip4:192.0.2.0 -all"'],
-        ('OPENPGPKEY', '00d8d3f11739d2f3537099982b4674c29fc59a8fda350fca1379613a._openpgpkey', 78000): [
-            'mQENBFnVAMgBCADWXo3I9Vig02zCR8WzGVN4FUrexZh9OdVSjOeSSmXPH6V5'
-            '+sWRfgSvtUp77IWQtZU810EI4GgcEzg30SEdLBSYZAt/lRWSpcQWnql4LvPg'
-            'oMqU+/+WUxFdnbIDGCMEwWzF2NtQwl4r/ot/q5SHoaA4AGtDarjA1pbTBxza'
-            '/xh6VRQLl5vhWRXKslh/Tm4NEBD16Z9gZ1CQ7YlAU5Mg5Io4ghOnxWZCGJHV'
-            '5BVQTrzzozyILny3e48dIwXJKgcFt/DhE+L9JTrO4cYtkG49k7a5biMiYhKh'
-            'LK3nvi5diyPyHYQfUaD5jO5Rfcgwk7L4LFinVmNllqL1mgoxadpgPE8xABEB'
-            'AAG0MUpvaGFubmVzIFdlYmVyIChPTkxZLVRFU1QpIDxqb2hhbm5lc0B3ZWJl'
-            'cmRucy5kZT6JATgEEwECACIFAlnVAMgCGwMGCwkIBwMCBhUIAgkKCwQWAgMB'
-            'Ah4BAheAAAoJEOvytPeP0jpogccH/1IQNza/JPiQRFLWwzz1mxOSgRgubkOw'
-            '+XgXAtvIGHQOF6/ZadQ8rNrMb3D+dS4bTkwpFemY59Bm3n12Ve2Wv2AdN8nK'
-            '1KLClA9cP8380CT53+zygV+mGfoRBLRO0i4QmW3mI6yg7T2E+U20j/i9IT1K'
-            'ATg4oIIgLn2bSpxRtuSp6aJ2q91Y/lne7Af7KbKq/MirEDeSPrjMYxK9D74E'
-            'ABLs4Ab4Rebg3sUga037yTOCYDpRv2xkyARoXMWYlRqME/in7aBtfo/fduJG'
-            'qu2RlND4inQmV75V+s4/x9u+7UlyFIMbWX2rtdWHsO/t4sCP1hhTZxz7kvK7'
-            '1ZqLj9hVjdW5AQ0EWdUAyAEIAKxTR0AcpiDm4r4Zt/qGD9P9jasNR0qkoHjr'
-            '9tmkaW34Lx7wNTDbSYQwn+WFzoT1rxbpge+IpjMn5KabHc0vh13vO1zdxvc0'
-            'LSydhjMI1Gfey+rsQxhT4p5TbvKpsWiNykSNryl1LRgRvcWMnxvYfxdyqIF2'
-            '3+3pgMipXlfJHX4SoAuPn4Bra84y0ziljrptWf4U78+QonX9dwwZ/SCrSPfQ'
-            'rGwpQcHSbbxZvxmgxeweHuAEhUGVuwkFsNBSk4NSi+7Y1p0/oD7tEM17WjnO'
-            'NuoGCFh1anTS7+LE0f3Mp0A74GeJvnkgdnPHJwcZpBf5Jf1/6Nw/tJpYiP9v'
-            'Fu1nF9EAEQEAAYkBHwQYAQIACQUCWdUAyAIbDAAKCRDr8rT3j9I6aDZrB/9j'
-            '2sgCohhDBr/Yzxlg3OmRwnvJlHjs//57XV99ssWAg142HxMQt87s/AXpIuKH'
-            'tupEAClN/knrmKubO3JUkoi3zCDkFkSgrH2Mos75KQbspUtmzwVeGiYSNqyG'
-            'pEzh5UWYuigYx1/a5pf3EhXCVVybIJwxDEo6sKZwYe6CRe5fQpY6eqZNKjkl'
-            '4xDogTMpsrty3snjZHOsQYlTlFWFsm1KA43Mnaj7Pfn35+8bBeNSgiS8R+EL'
-            'f66Ymcl9YHWHHTXjs+DvsrimYbs1GXOyuu3tHfKlZH19ZevXbycpp4UFWsOk'
-            'Sxsb3CZRnPxuz+NjZrOk3UNI6RxlaeuAQOBEow50'],
-        ('PTR', 'foo', 1010): ['1.example.com.', '2.example.com.'],
-        ('SRV', '*', 100023): ['10 60 5060 1.example.com.', '20 60 5060 2.example.com.', '30 60 5060 3.example.com.'],
-        ('TLSA', '_443._tcp.www', 89): ['3 0 1 221C1A9866C32A45E44F55F611303242082A01C1B5C3027C8C7AD1324DE0AC38'],
+        ("SPF", "baz", 444): [
+            '"v=spf1 ip4:192.0.2.0/24 ip4:198.51.100.123 a -all"',
+            '"v=spf1 a mx ip4:192.0.2.0 -all"',
+        ],
+        (
+            "OPENPGPKEY",
+            "00d8d3f11739d2f3537099982b4674c29fc59a8fda350fca1379613a._openpgpkey",
+            78000,
+        ): [
+            "mQENBFnVAMgBCADWXo3I9Vig02zCR8WzGVN4FUrexZh9OdVSjOeSSmXPH6V5"
+            "+sWRfgSvtUp77IWQtZU810EI4GgcEzg30SEdLBSYZAt/lRWSpcQWnql4LvPg"
+            "oMqU+/+WUxFdnbIDGCMEwWzF2NtQwl4r/ot/q5SHoaA4AGtDarjA1pbTBxza"
+            "/xh6VRQLl5vhWRXKslh/Tm4NEBD16Z9gZ1CQ7YlAU5Mg5Io4ghOnxWZCGJHV"
+            "5BVQTrzzozyILny3e48dIwXJKgcFt/DhE+L9JTrO4cYtkG49k7a5biMiYhKh"
+            "LK3nvi5diyPyHYQfUaD5jO5Rfcgwk7L4LFinVmNllqL1mgoxadpgPE8xABEB"
+            "AAG0MUpvaGFubmVzIFdlYmVyIChPTkxZLVRFU1QpIDxqb2hhbm5lc0B3ZWJl"
+            "cmRucy5kZT6JATgEEwECACIFAlnVAMgCGwMGCwkIBwMCBhUIAgkKCwQWAgMB"
+            "Ah4BAheAAAoJEOvytPeP0jpogccH/1IQNza/JPiQRFLWwzz1mxOSgRgubkOw"
+            "+XgXAtvIGHQOF6/ZadQ8rNrMb3D+dS4bTkwpFemY59Bm3n12Ve2Wv2AdN8nK"
+            "1KLClA9cP8380CT53+zygV+mGfoRBLRO0i4QmW3mI6yg7T2E+U20j/i9IT1K"
+            "ATg4oIIgLn2bSpxRtuSp6aJ2q91Y/lne7Af7KbKq/MirEDeSPrjMYxK9D74E"
+            "ABLs4Ab4Rebg3sUga037yTOCYDpRv2xkyARoXMWYlRqME/in7aBtfo/fduJG"
+            "qu2RlND4inQmV75V+s4/x9u+7UlyFIMbWX2rtdWHsO/t4sCP1hhTZxz7kvK7"
+            "1ZqLj9hVjdW5AQ0EWdUAyAEIAKxTR0AcpiDm4r4Zt/qGD9P9jasNR0qkoHjr"
+            "9tmkaW34Lx7wNTDbSYQwn+WFzoT1rxbpge+IpjMn5KabHc0vh13vO1zdxvc0"
+            "LSydhjMI1Gfey+rsQxhT4p5TbvKpsWiNykSNryl1LRgRvcWMnxvYfxdyqIF2"
+            "3+3pgMipXlfJHX4SoAuPn4Bra84y0ziljrptWf4U78+QonX9dwwZ/SCrSPfQ"
+            "rGwpQcHSbbxZvxmgxeweHuAEhUGVuwkFsNBSk4NSi+7Y1p0/oD7tEM17WjnO"
+            "NuoGCFh1anTS7+LE0f3Mp0A74GeJvnkgdnPHJwcZpBf5Jf1/6Nw/tJpYiP9v"
+            "Fu1nF9EAEQEAAYkBHwQYAQIACQUCWdUAyAIbDAAKCRDr8rT3j9I6aDZrB/9j"
+            "2sgCohhDBr/Yzxlg3OmRwnvJlHjs//57XV99ssWAg142HxMQt87s/AXpIuKH"
+            "tupEAClN/knrmKubO3JUkoi3zCDkFkSgrH2Mos75KQbspUtmzwVeGiYSNqyG"
+            "pEzh5UWYuigYx1/a5pf3EhXCVVybIJwxDEo6sKZwYe6CRe5fQpY6eqZNKjkl"
+            "4xDogTMpsrty3snjZHOsQYlTlFWFsm1KA43Mnaj7Pfn35+8bBeNSgiS8R+EL"
+            "f66Ymcl9YHWHHTXjs+DvsrimYbs1GXOyuu3tHfKlZH19ZevXbycpp4UFWsOk"
+            "Sxsb3CZRnPxuz+NjZrOk3UNI6RxlaeuAQOBEow50"
+        ],
+        ("PTR", "foo", 1010): ["1.example.com.", "2.example.com."],
+        ("SRV", "*", 100023): [
+            "10 60 5060 1.example.com.",
+            "20 60 5060 2.example.com.",
+            "30 60 5060 3.example.com.",
+        ],
+        ("TLSA", "_443._tcp.www", 89): [
+            "3 0 1 221C1A9866C32A45E44F55F611303242082A01C1B5C3027C8C7AD1324DE0AC38"
+        ],
     }
     }
 
 
 
 
 class DomainTestCase(PdnsChangeTrackerTestCase):
 class DomainTestCase(PdnsChangeTrackerTestCase):
-
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
         self.full_domain = None
         self.full_domain = None
@@ -449,31 +550,44 @@ class DomainTestCase(PdnsChangeTrackerTestCase):
 
 
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
-        self.empty_domain = Domain.objects.create(name=self.random_domain_name(), owner=self.user)
-        self.simple_domain = Domain.objects.create(name=self.random_domain_name(), owner=self.user)
-        self.full_domain = Domain.objects.create(name=self.random_domain_name(), owner=self.user)
+        self.empty_domain = Domain.objects.create(
+            name=self.random_domain_name(), owner=self.user
+        )
+        self.simple_domain = Domain.objects.create(
+            name=self.random_domain_name(), owner=self.user
+        )
+        self.full_domain = Domain.objects.create(
+            name=self.random_domain_name(), owner=self.user
+        )
         self.domains = [self.empty_domain, self.simple_domain, self.full_domain]
         self.domains = [self.empty_domain, self.simple_domain, self.full_domain]
 
 
-        simple_rr_set = RRset.objects.create(domain=self.simple_domain, type='AAAA', subname='', ttl=42)
-        RR.objects.create(content='::1', rrset=simple_rr_set)
-        RR.objects.create(content='::2', rrset=simple_rr_set)
+        simple_rr_set = RRset.objects.create(
+            domain=self.simple_domain, type="AAAA", subname="", ttl=42
+        )
+        RR.objects.create(content="::1", rrset=simple_rr_set)
+        RR.objects.create(content="::2", rrset=simple_rr_set)
 
 
-        rr_set_1 = RRset.objects.create(domain=self.full_domain, type='A', subname='*', ttl=1337)
+        rr_set_1 = RRset.objects.create(
+            domain=self.full_domain, type="A", subname="*", ttl=1337
+        )
         for content in [self.random_ip(4) for _ in range(10)]:
         for content in [self.random_ip(4) for _ in range(10)]:
             RR.objects.create(content=content, rrset=rr_set_1)
             RR.objects.create(content=content, rrset=rr_set_1)
-        rr_set_2 = RRset.objects.create(domain=self.full_domain, type='AAAA', subname='', ttl=60)
+        rr_set_2 = RRset.objects.create(
+            domain=self.full_domain, type="AAAA", subname="", ttl=60
+        )
         for content in [self.random_ip(6) for _ in range(15)]:
         for content in [self.random_ip(6) for _ in range(15)]:
             RR.objects.create(content=content, rrset=rr_set_2)
             RR.objects.create(content=content, rrset=rr_set_2)
 
 
     def test_create(self):
     def test_create(self):
         name = self.random_domain_name()
         name = self.random_domain_name()
         with self.assertPdnsRequests(
         with self.assertPdnsRequests(
-                [
-                    self.request_pdns_zone_create('LORD'),
-                    self.request_pdns_zone_create('MASTER'),
-                    self.request_pdns_update_catalog(),
-                    self.request_pdns_zone_axfr(name)
-                ]), PDNSChangeTracker():
+            [
+                self.request_pdns_zone_create("LORD"),
+                self.request_pdns_zone_create("MASTER"),
+                self.request_pdns_update_catalog(),
+                self.request_pdns_zone_axfr(name),
+            ]
+        ), PDNSChangeTracker():
             Domain.objects.create(name=name, owner=self.user)
             Domain.objects.create(name=name, owner=self.user)
 
 
     def test_update_domain(self):
     def test_update_domain(self):
@@ -491,13 +605,19 @@ class DomainTestCase(PdnsChangeTrackerTestCase):
 
 
     def test_delete_single(self):
     def test_delete_single(self):
         for domain in self.domains:
         for domain in self.domains:
-            with self.assertPdnsRequests(self.requests_desec_domain_deletion(domain)), PDNSChangeTracker():
+            with self.assertPdnsRequests(
+                self.requests_desec_domain_deletion(domain)
+            ), PDNSChangeTracker():
                 domain.delete()
                 domain.delete()
 
 
     def test_delete_multiple(self):
     def test_delete_multiple(self):
-        with self.assertPdnsRequests([
-            self.requests_desec_domain_deletion(domain) for domain in reversed(self.domains)
-        ], expect_order=False), PDNSChangeTracker():
+        with self.assertPdnsRequests(
+            [
+                self.requests_desec_domain_deletion(domain)
+                for domain in reversed(self.domains)
+            ],
+            expect_order=False,
+        ), PDNSChangeTracker():
             for domain in self.domains:
             for domain in self.domains:
                 domain.delete()
                 domain.delete()
 
 

+ 14 - 12
api/desecapi/tests/test_replication.py

@@ -7,26 +7,28 @@ from desecapi.tests.base import DesecTestCase
 
 
 class ReplicationTest(DesecTestCase):
 class ReplicationTest(DesecTestCase):
     def test_serials(self):
     def test_serials(self):
-        url = self.reverse('v1:serial')
+        url = self.reverse("v1:serial")
         zones = [
         zones = [
-            {'name': 'test.example.', 'edited_serial': 12345},
-            {'name': 'example.org.', 'edited_serial': 54321},
+            {"name": "test.example.", "edited_serial": 12345},
+            {"name": "example.org.", "edited_serial": 54321},
+        ]
+        serials = {zone["name"]: zone["edited_serial"] for zone in zones}
+        pdns_requests = [
+            {
+                "method": "GET",
+                "uri": self.get_full_pdns_url(r"/zones", ns="MASTER"),
+                "status": 200,
+                "body": json.dumps(zones),
+            }
         ]
         ]
-        serials = {zone['name']: zone['edited_serial'] for zone in zones}
-        pdns_requests = [{
-            'method': 'GET',
-            'uri': self.get_full_pdns_url(r'/zones', ns='MASTER'),
-            'status': 200,
-            'body': json.dumps(zones),
-        }]
 
 
         # Run twice to make sure cache output varies on remote address
         # Run twice to make sure cache output varies on remote address
         for i in range(2):
         for i in range(2):
-            response = self.client.get(path=url, REMOTE_ADDR='123.8.0.2')
+            response = self.client.get(path=url, REMOTE_ADDR="123.8.0.2")
             self.assertStatus(response, status.HTTP_401_UNAUTHORIZED)
             self.assertStatus(response, status.HTTP_401_UNAUTHORIZED)
 
 
             with self.assertPdnsRequests(pdns_requests):
             with self.assertPdnsRequests(pdns_requests):
-                response = self.client.get(path=url, REMOTE_ADDR='10.8.0.2')
+                response = self.client.get(path=url, REMOTE_ADDR="10.8.0.2")
             self.assertStatus(response, status.HTTP_200_OK)
             self.assertStatus(response, status.HTTP_200_OK)
             self.assertEqual(response.data, serials)
             self.assertEqual(response.data, serials)
 
 

File diff suppressed because it is too large
+ 607 - 287
api/desecapi/tests/test_rrsets.py


+ 463 - 185
api/desecapi/tests/test_rrsets_bulk.py

@@ -7,44 +7,57 @@ from desecapi.tests.base import AuthenticatedRRSetBaseTestCase
 
 
 
 
 class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
 class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
-
     @classmethod
     @classmethod
     def setUpTestDataWithPdns(cls):
     def setUpTestDataWithPdns(cls):
         super().setUpTestDataWithPdns()
         super().setUpTestDataWithPdns()
 
 
         cls.data = [
         cls.data = [
-            {'subname': 'my-cname', 'records': ['example.com.'], 'ttl': 3600, 'type': 'CNAME'},
-            {'subname': 'my-bulk', 'records': ['desec.io.', 'foobar.example.'], 'ttl': 3600, 'type': 'PTR'},
+            {
+                "subname": "my-cname",
+                "records": ["example.com."],
+                "ttl": 3600,
+                "type": "CNAME",
+            },
+            {
+                "subname": "my-bulk",
+                "records": ["desec.io.", "foobar.example."],
+                "ttl": 3600,
+                "type": "PTR",
+            },
         ]
         ]
 
 
         cls.data_no_records = copy.deepcopy(cls.data)
         cls.data_no_records = copy.deepcopy(cls.data)
-        cls.data_no_records[1].pop('records')
+        cls.data_no_records[1].pop("records")
 
 
         cls.data_empty_records = copy.deepcopy(cls.data)
         cls.data_empty_records = copy.deepcopy(cls.data)
-        cls.data_empty_records[1]['records'] = []
+        cls.data_empty_records[1]["records"] = []
 
 
         cls.data_no_subname = copy.deepcopy(cls.data)
         cls.data_no_subname = copy.deepcopy(cls.data)
-        cls.data_no_subname[0].pop('subname')
+        cls.data_no_subname[0].pop("subname")
 
 
         cls.data_no_ttl = copy.deepcopy(cls.data)
         cls.data_no_ttl = copy.deepcopy(cls.data)
-        cls.data_no_ttl[0].pop('ttl')
+        cls.data_no_ttl[0].pop("ttl")
 
 
         cls.data_no_type = copy.deepcopy(cls.data)
         cls.data_no_type = copy.deepcopy(cls.data)
-        cls.data_no_type[1].pop('type')
+        cls.data_no_type[1].pop("type")
 
 
         cls.data_no_records_no_ttl = copy.deepcopy(cls.data_no_records)
         cls.data_no_records_no_ttl = copy.deepcopy(cls.data_no_records)
-        cls.data_no_records_no_ttl[1].pop('ttl')
+        cls.data_no_records_no_ttl[1].pop("ttl")
 
 
         cls.data_no_subname_empty_records = copy.deepcopy(cls.data_no_subname)
         cls.data_no_subname_empty_records = copy.deepcopy(cls.data_no_subname)
-        cls.data_no_subname_empty_records[0]['records'] = []
+        cls.data_no_subname_empty_records[0]["records"] = []
 
 
         cls.bulk_domain = cls.create_domain(owner=cls.owner)
         cls.bulk_domain = cls.create_domain(owner=cls.owner)
         for data in cls.data:
         for data in cls.data:
             cls.create_rr_set(cls.bulk_domain, **data)
             cls.create_rr_set(cls.bulk_domain, **data)
 
 
     def test_bulk_post_my_rr_sets(self):
     def test_bulk_post_my_rr_sets(self):
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_empty_domain.name)):
-            response = self.client.bulk_post_rr_sets(domain_name=self.my_empty_domain.name, payload=self.data)
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(name=self.my_empty_domain.name)
+        ):
+            response = self.client.bulk_post_rr_sets(
+                domain_name=self.my_empty_domain.name, payload=self.data
+            )
             self.assertStatus(response, status.HTTP_201_CREATED)
             self.assertStatus(response, status.HTTP_201_CREATED)
 
 
         response = self.client.get_rr_sets(self.my_empty_domain.name)
         response = self.client.get_rr_sets(self.my_empty_domain.name)
@@ -53,26 +66,33 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
 
 
         # Check subname requirement on bulk endpoint (and uniqueness at the same time)
         # Check subname requirement on bulk endpoint (and uniqueness at the same time)
         self.assertResponse(
         self.assertResponse(
-            self.client.bulk_post_rr_sets(domain_name=self.my_empty_domain.name, payload=self.data_no_subname),
+            self.client.bulk_post_rr_sets(
+                domain_name=self.my_empty_domain.name, payload=self.data_no_subname
+            ),
             status.HTTP_400_BAD_REQUEST,
             status.HTTP_400_BAD_REQUEST,
             [
             [
-                {'subname': ['This field is required.']},
-                {'non_field_errors': ['Another RRset with the same subdomain and type exists for this domain.']}
-            ]
+                {"subname": ["This field is required."]},
+                {
+                    "non_field_errors": [
+                        "Another RRset with the same subdomain and type exists for this domain."
+                    ]
+                },
+            ],
         )
         )
 
 
     def test_bulk_post_rr_sets_empty_records(self):
     def test_bulk_post_rr_sets_empty_records(self):
         expected_response_data = [copy.deepcopy(self.data_empty_records[0]), None]
         expected_response_data = [copy.deepcopy(self.data_empty_records[0]), None]
-        expected_response_data[0]['domain'] = self.my_empty_domain.name
-        expected_response_data[0]['name'] = '%s.%s.' % (self.data_empty_records[0]['subname'],
-                                                        self.my_empty_domain.name)
+        expected_response_data[0]["domain"] = self.my_empty_domain.name
+        expected_response_data[0]["name"] = "%s.%s." % (
+            self.data_empty_records[0]["subname"],
+            self.my_empty_domain.name,
+        )
         self.assertResponse(
         self.assertResponse(
-            self.client.bulk_post_rr_sets(domain_name=self.my_empty_domain.name, payload=self.data_empty_records),
+            self.client.bulk_post_rr_sets(
+                domain_name=self.my_empty_domain.name, payload=self.data_empty_records
+            ),
             status.HTTP_400_BAD_REQUEST,
             status.HTTP_400_BAD_REQUEST,
-            [
-                {},
-                {'records': ['This field must not be empty when using POST.']}
-            ]
+            [{}, {"records": ["This field must not be empty when using POST."]}],
         )
         )
 
 
     def test_bulk_post_existing_rrsets(self):
     def test_bulk_post_existing_rrsets(self):
@@ -82,33 +102,62 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
                 payload=self.data,
                 payload=self.data,
             ),
             ),
             status.HTTP_400_BAD_REQUEST,
             status.HTTP_400_BAD_REQUEST,
-            2 * [{
-                'non_field_errors': ['Another RRset with the same subdomain and type exists for this domain.']
-            }]
+            2
+            * [
+                {
+                    "non_field_errors": [
+                        "Another RRset with the same subdomain and type exists for this domain."
+                    ]
+                }
+            ],
         )
         )
 
 
     def test_bulk_post_duplicates(self):
     def test_bulk_post_duplicates(self):
         data = 2 * [self.data[0]] + [self.data[1]]
         data = 2 * [self.data[0]] + [self.data[1]]
         self.assertResponse(
         self.assertResponse(
-            self.client.bulk_post_rr_sets(domain_name=self.my_empty_domain.name, payload=data),
+            self.client.bulk_post_rr_sets(
+                domain_name=self.my_empty_domain.name, payload=data
+            ),
             status.HTTP_400_BAD_REQUEST,
             status.HTTP_400_BAD_REQUEST,
             [
             [
-                {'non_field_errors': ['Same subname and type as in position(s) 1, but must be unique.']},
-                {'non_field_errors': ['Same subname and type as in position(s) 0, but must be unique.']},
+                {
+                    "non_field_errors": [
+                        "Same subname and type as in position(s) 1, but must be unique."
+                    ]
+                },
+                {
+                    "non_field_errors": [
+                        "Same subname and type as in position(s) 0, but must be unique."
+                    ]
+                },
                 {},
                 {},
-            ]
+            ],
         )
         )
 
 
         data = 2 * [self.data[0]] + [self.data[1]] + [self.data[0]]
         data = 2 * [self.data[0]] + [self.data[1]] + [self.data[0]]
         self.assertResponse(
         self.assertResponse(
-            self.client.bulk_post_rr_sets(domain_name=self.my_empty_domain.name, payload=data),
+            self.client.bulk_post_rr_sets(
+                domain_name=self.my_empty_domain.name, payload=data
+            ),
             status.HTTP_400_BAD_REQUEST,
             status.HTTP_400_BAD_REQUEST,
             [
             [
-                {'non_field_errors': ['Same subname and type as in position(s) 1, 3, but must be unique.']},
-                {'non_field_errors': ['Same subname and type as in position(s) 0, 3, but must be unique.']},
+                {
+                    "non_field_errors": [
+                        "Same subname and type as in position(s) 1, 3, but must be unique."
+                    ]
+                },
+                {
+                    "non_field_errors": [
+                        "Same subname and type as in position(s) 0, 3, but must be unique."
+                    ]
+                },
                 {},
                 {},
-                {'non_field_errors': ['Same subname and type as in position(s) 0, 1, but must be unique.']},
-            ]
+                {
+                    "non_field_errors": [
+                        "Same subname and type as in position(s) 0, 1, but must be unique."
+                    ]
+                },
+            ],
         )
         )
 
 
     def test_bulk_post_missing_fields(self):
     def test_bulk_post_missing_fields(self):
@@ -116,125 +165,221 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
             self.client.bulk_post_rr_sets(
             self.client.bulk_post_rr_sets(
                 domain_name=self.my_empty_domain.name,
                 domain_name=self.my_empty_domain.name,
                 payload=[
                 payload=[
-                    {'subname': 'a.1', 'records': ['dead::beef'], 'ttl': 3622},
-                    {'subname': 'b.1', 'ttl': -50, 'type': 'AAAA', 'records': ['dead::beef']},
-                    {'ttl': 3640, 'type': 'TXT', 'records': ['"bar"']},
-                    {'subname': '', 'ttl': 3640, 'type': 'TXT', 'records': ['"bar"']},
-                    {'subname': 'c.1', 'records': ['dead::beef'], 'type': 'AAAA'},
-                    {'subname': 'd.1', 'ttl': 3650, 'type': 'AAAA'},
-                    {'subname': 'd.1', 'ttl': 3650, 'type': 'SOA',
-                     'records': ['get.desec.io. get.desec.io. 2018034419 10800 3600 604800 60']},
-                    {'subname': 'd.1', 'ttl': 3650, 'type': 'OPT', 'records': ['9999']},
-                    {'subname': 'd.1', 'ttl': 3650, 'type': 'TYPE099', 'records': ['v=spf1 mx -all']},
-                ]
+                    {"subname": "a.1", "records": ["dead::beef"], "ttl": 3622},
+                    {
+                        "subname": "b.1",
+                        "ttl": -50,
+                        "type": "AAAA",
+                        "records": ["dead::beef"],
+                    },
+                    {"ttl": 3640, "type": "TXT", "records": ['"bar"']},
+                    {"subname": "", "ttl": 3640, "type": "TXT", "records": ['"bar"']},
+                    {"subname": "c.1", "records": ["dead::beef"], "type": "AAAA"},
+                    {"subname": "d.1", "ttl": 3650, "type": "AAAA"},
+                    {
+                        "subname": "d.1",
+                        "ttl": 3650,
+                        "type": "SOA",
+                        "records": [
+                            "get.desec.io. get.desec.io. 2018034419 10800 3600 604800 60"
+                        ],
+                    },
+                    {"subname": "d.1", "ttl": 3650, "type": "OPT", "records": ["9999"]},
+                    {
+                        "subname": "d.1",
+                        "ttl": 3650,
+                        "type": "TYPE099",
+                        "records": ["v=spf1 mx -all"],
+                    },
+                ],
             ),
             ),
             status.HTTP_400_BAD_REQUEST,
             status.HTTP_400_BAD_REQUEST,
             [
             [
-                {'type': ['This field is required.']},
-                {'ttl': [f'Ensure this value is greater than or equal to {self.my_empty_domain.minimum_ttl}.']},
-                {'subname': ['This field is required.']},
+                {"type": ["This field is required."]},
+                {
+                    "ttl": [
+                        f"Ensure this value is greater than or equal to {self.my_empty_domain.minimum_ttl}."
+                    ]
+                },
+                {"subname": ["This field is required."]},
                 {},
                 {},
-                {'ttl': ['This field is required.']},
-                {'records': ['This field is required.']},
-                {'type': ['You cannot tinker with the SOA RR set. It is managed automatically.']},
-                {'type': ['You cannot tinker with the OPT RR set. It is managed automatically.']},
-                {'type': ['Generic type format is not supported.']},
-            ]
+                {"ttl": ["This field is required."]},
+                {"records": ["This field is required."]},
+                {
+                    "type": [
+                        "You cannot tinker with the SOA RR set. It is managed automatically."
+                    ]
+                },
+                {
+                    "type": [
+                        "You cannot tinker with the OPT RR set. It is managed automatically."
+                    ]
+                },
+                {"type": ["Generic type format is not supported."]},
+            ],
         )
         )
 
 
     def test_bulk_patch_cname_exclusivity(self):
     def test_bulk_patch_cname_exclusivity(self):
         response = self.client.bulk_patch_rr_sets(
         response = self.client.bulk_patch_rr_sets(
             domain_name=self.my_rr_set_domain.name,
             domain_name=self.my_rr_set_domain.name,
             payload=[
             payload=[
-                {'subname': 'test', 'type': 'A', 'ttl': 3600, 'records': ['1.2.3.4']},
-                {'subname': 'test', 'type': 'CNAME', 'ttl': 3600, 'records': ['example.com.']},
-            ]
+                {"subname": "test", "type": "A", "ttl": 3600, "records": ["1.2.3.4"]},
+                {
+                    "subname": "test",
+                    "type": "CNAME",
+                    "ttl": 3600,
+                    "records": ["example.com."],
+                },
+            ],
         )
         )
         self.assertResponse(response, status.HTTP_400_BAD_REQUEST)
         self.assertResponse(response, status.HTTP_400_BAD_REQUEST)
-        self.assertEqual(response.json(), [
-            {"non_field_errors":["RRset with conflicting type present: 1 (CNAME). (No other RRsets are allowed alongside CNAME.)"]},
-            {"non_field_errors":["RRset with conflicting type present: 0 (A), database (A, TXT). (No other RRsets are allowed alongside CNAME.)"]},
-        ])
+        self.assertEqual(
+            response.json(),
+            [
+                {
+                    "non_field_errors": [
+                        "RRset with conflicting type present: 1 (CNAME). (No other RRsets are allowed alongside CNAME.)"
+                    ]
+                },
+                {
+                    "non_field_errors": [
+                        "RRset with conflicting type present: 0 (A), database (A, TXT). (No other RRsets are allowed alongside CNAME.)"
+                    ]
+                },
+            ],
+        )
 
 
     def test_bulk_post_accepts_empty_list(self):
     def test_bulk_post_accepts_empty_list(self):
         self.assertResponse(
         self.assertResponse(
-            self.client.bulk_post_rr_sets(domain_name=self.my_empty_domain.name, payload=[]),
+            self.client.bulk_post_rr_sets(
+                domain_name=self.my_empty_domain.name, payload=[]
+            ),
             status.HTTP_201_CREATED,
             status.HTTP_201_CREATED,
         )
         )
 
 
     def test_bulk_patch_fresh_rrsets_need_records(self):
     def test_bulk_patch_fresh_rrsets_need_records(self):
-        response = self.client.bulk_patch_rr_sets(self.my_empty_domain.name, payload=self.data_no_records)
+        response = self.client.bulk_patch_rr_sets(
+            self.my_empty_domain.name, payload=self.data_no_records
+        )
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-        self.assertEqual(response.data, [{}, {'records': ['This field is required.']}])
+        self.assertEqual(response.data, [{}, {"records": ["This field is required."]}])
 
 
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(self.my_empty_domain.name)):
-            response = self.client.bulk_patch_rr_sets(self.my_empty_domain.name, payload=self.data_empty_records)
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(self.my_empty_domain.name)
+        ):
+            response = self.client.bulk_patch_rr_sets(
+                self.my_empty_domain.name, payload=self.data_empty_records
+            )
             self.assertStatus(response, status.HTTP_200_OK)
             self.assertStatus(response, status.HTTP_200_OK)
 
 
     def test_bulk_patch_fresh_rrsets_need_subname(self):
     def test_bulk_patch_fresh_rrsets_need_subname(self):
-        response = self.client.bulk_patch_rr_sets(domain_name=self.my_empty_domain.name, payload=self.data_no_subname)
+        response = self.client.bulk_patch_rr_sets(
+            domain_name=self.my_empty_domain.name, payload=self.data_no_subname
+        )
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
 
 
     def test_bulk_patch_fresh_rrsets_need_ttl(self):
     def test_bulk_patch_fresh_rrsets_need_ttl(self):
-        response = self.client.bulk_patch_rr_sets(domain_name=self.my_empty_domain.name, payload=self.data_no_ttl)
+        response = self.client.bulk_patch_rr_sets(
+            domain_name=self.my_empty_domain.name, payload=self.data_no_ttl
+        )
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-        self.assertEqual(response.data, [{'ttl': ['This field is required.']}, {}])
+        self.assertEqual(response.data, [{"ttl": ["This field is required."]}, {}])
 
 
     def test_bulk_patch_fresh_rrsets_need_type(self):
     def test_bulk_patch_fresh_rrsets_need_type(self):
-        response = self.client.bulk_patch_rr_sets(domain_name=self.my_empty_domain.name, payload=self.data_no_type)
+        response = self.client.bulk_patch_rr_sets(
+            domain_name=self.my_empty_domain.name, payload=self.data_no_type
+        )
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-        self.assertEqual(response.data, [{}, {'type': ['This field is required.']}])
+        self.assertEqual(response.data, [{}, {"type": ["This field is required."]}])
 
 
     def test_bulk_patch_does_not_accept_single_objects(self):
     def test_bulk_patch_does_not_accept_single_objects(self):
-        response = self.client.bulk_patch_rr_sets(domain_name=self.my_empty_domain.name, payload=self.data[0])
-        self.assertContains(response, 'Expected a list of items but got dict.', status_code=status.HTTP_400_BAD_REQUEST)
+        response = self.client.bulk_patch_rr_sets(
+            domain_name=self.my_empty_domain.name, payload=self.data[0]
+        )
+        self.assertContains(
+            response,
+            "Expected a list of items but got dict.",
+            status_code=status.HTTP_400_BAD_REQUEST,
+        )
 
 
     def test_bulk_patch_does_accept_empty_list(self):
     def test_bulk_patch_does_accept_empty_list(self):
-        response = self.client.bulk_patch_rr_sets(domain_name=self.my_empty_domain.name, payload=[])
+        response = self.client.bulk_patch_rr_sets(
+            domain_name=self.my_empty_domain.name, payload=[]
+        )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
 
 
-        response = self.client.bulk_patch_rr_sets(domain_name=self.my_rr_set_domain.name, payload=[])
+        response = self.client.bulk_patch_rr_sets(
+            domain_name=self.my_rr_set_domain.name, payload=[]
+        )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
 
 
     def test_bulk_patch_does_not_accept_empty_payload(self):
     def test_bulk_patch_does_not_accept_empty_payload(self):
-        response = self.client.bulk_patch_rr_sets(domain_name=self.my_empty_domain.name, payload=None)
-        self.assertContains(response, 'No data provided', status_code=status.HTTP_400_BAD_REQUEST)
+        response = self.client.bulk_patch_rr_sets(
+            domain_name=self.my_empty_domain.name, payload=None
+        )
+        self.assertContains(
+            response, "No data provided", status_code=status.HTTP_400_BAD_REQUEST
+        )
 
 
     def test_bulk_patch_cname_exclusivity_atomic_rrset_replacement(self):
     def test_bulk_patch_cname_exclusivity_atomic_rrset_replacement(self):
-        self.create_rr_set(self.my_empty_domain, subname='test', type='A', records=['1.2.3.4'], ttl=3600)
+        self.create_rr_set(
+            self.my_empty_domain,
+            subname="test",
+            type="A",
+            records=["1.2.3.4"],
+            ttl=3600,
+        )
 
 
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(self.my_empty_domain.name)):
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(self.my_empty_domain.name)
+        ):
             response = self.client.bulk_patch_rr_sets(
             response = self.client.bulk_patch_rr_sets(
                 domain_name=self.my_empty_domain.name,
                 domain_name=self.my_empty_domain.name,
                 payload=[
                 payload=[
-                    {'subname': 'test', 'type': 'CNAME', 'ttl': 3605, 'records': ['example.com.']},
-                    {'subname': 'test', 'type': 'A', 'records': []},
-                ]
+                    {
+                        "subname": "test",
+                        "type": "CNAME",
+                        "ttl": 3605,
+                        "records": ["example.com."],
+                    },
+                    {"subname": "test", "type": "A", "records": []},
+                ],
             )
             )
             self.assertResponse(response, status.HTTP_200_OK)
             self.assertResponse(response, status.HTTP_200_OK)
             self.assertEqual(len(response.data), 1)
             self.assertEqual(len(response.data), 1)
-            self.assertEqual(response.data[0]['type'], 'CNAME')
-            self.assertEqual(response.data[0]['records'], ['example.com.'])
-            self.assertEqual(response.data[0]['ttl'], 3605)
+            self.assertEqual(response.data[0]["type"], "CNAME")
+            self.assertEqual(response.data[0]["records"], ["example.com."])
+            self.assertEqual(response.data[0]["ttl"], 3605)
 
 
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(self.my_empty_domain.name)):
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(self.my_empty_domain.name)
+        ):
             response = self.client.bulk_patch_rr_sets(
             response = self.client.bulk_patch_rr_sets(
                 domain_name=self.my_empty_domain.name,
                 domain_name=self.my_empty_domain.name,
                 payload=[
                 payload=[
-                    {'subname': 'test', 'type': 'CNAME', 'records': []},
-                    {'subname': 'test', 'type': 'A', 'ttl': 3600, 'records': ['5.4.2.1']},
-                ]
+                    {"subname": "test", "type": "CNAME", "records": []},
+                    {
+                        "subname": "test",
+                        "type": "A",
+                        "ttl": 3600,
+                        "records": ["5.4.2.1"],
+                    },
+                ],
             )
             )
             self.assertResponse(response, status.HTTP_200_OK)
             self.assertResponse(response, status.HTTP_200_OK)
             self.assertEqual(len(response.data), 1)
             self.assertEqual(len(response.data), 1)
-            self.assertEqual(response.data[0]['type'], 'A')
-            self.assertEqual(response.data[0]['records'], ['5.4.2.1'])
-            self.assertEqual(response.data[0]['ttl'], 3600)
+            self.assertEqual(response.data[0]["type"], "A")
+            self.assertEqual(response.data[0]["records"], ["5.4.2.1"])
+            self.assertEqual(response.data[0]["ttl"], 3600)
 
 
     def test_bulk_patch_full_on_empty_domain(self):
     def test_bulk_patch_full_on_empty_domain(self):
         # Full patch always works
         # Full patch always works
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_empty_domain.name)):
-            response = self.client.bulk_patch_rr_sets(domain_name=self.my_empty_domain.name, payload=self.data)
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(name=self.my_empty_domain.name)
+        ):
+            response = self.client.bulk_patch_rr_sets(
+                domain_name=self.my_empty_domain.name, payload=self.data
+            )
             self.assertStatus(response, status.HTTP_200_OK)
             self.assertStatus(response, status.HTTP_200_OK)
 
 
         # Check that RRsets have been created
         # Check that RRsets have been created
@@ -244,9 +389,13 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
 
 
     def test_bulk_patch_change_records(self):
     def test_bulk_patch_change_records(self):
         data_no_ttl = copy.deepcopy(self.data_no_ttl)
         data_no_ttl = copy.deepcopy(self.data_no_ttl)
-        data_no_ttl[0]['records'] = ['example.org.']
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.bulk_domain.name)):
-            response = self.client.bulk_patch_rr_sets(domain_name=self.bulk_domain.name, payload=data_no_ttl)
+        data_no_ttl[0]["records"] = ["example.org."]
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(name=self.bulk_domain.name)
+        ):
+            response = self.client.bulk_patch_rr_sets(
+                domain_name=self.bulk_domain.name, payload=data_no_ttl
+            )
             self.assertStatus(response, status.HTTP_200_OK)
             self.assertStatus(response, status.HTTP_200_OK)
 
 
         response = self.client.get_rr_sets(self.bulk_domain.name)
         response = self.client.get_rr_sets(self.bulk_domain.name)
@@ -255,9 +404,13 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
 
 
     def test_bulk_patch_change_ttl(self):
     def test_bulk_patch_change_ttl(self):
         data_no_records = copy.deepcopy(self.data_no_records)
         data_no_records = copy.deepcopy(self.data_no_records)
-        data_no_records[1]['ttl'] = 3911
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.bulk_domain.name)):
-            response = self.client.bulk_patch_rr_sets(domain_name=self.bulk_domain.name, payload=data_no_records)
+        data_no_records[1]["ttl"] = 3911
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(name=self.bulk_domain.name)
+        ):
+            response = self.client.bulk_patch_rr_sets(
+                domain_name=self.bulk_domain.name, payload=data_no_records
+            )
             self.assertStatus(response, status.HTTP_200_OK)
             self.assertStatus(response, status.HTTP_200_OK)
 
 
         response = self.client.get_rr_sets(self.bulk_domain.name)
         response = self.client.get_rr_sets(self.bulk_domain.name)
@@ -266,7 +419,9 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
 
 
     def test_bulk_patch_does_not_need_ttl(self):
     def test_bulk_patch_does_not_need_ttl(self):
         self.assertResponse(
         self.assertResponse(
-            self.client.bulk_patch_rr_sets(domain_name=self.bulk_domain.name, payload=self.data_no_ttl),
+            self.client.bulk_patch_rr_sets(
+                domain_name=self.bulk_domain.name, payload=self.data_no_ttl
+            ),
             status.HTTP_200_OK,
             status.HTTP_200_OK,
         )
         )
 
 
@@ -275,135 +430,241 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
             self.client.bulk_patch_rr_sets(
             self.client.bulk_patch_rr_sets(
                 domain_name=self.my_empty_domain.name,
                 domain_name=self.my_empty_domain.name,
                 payload=[
                 payload=[
-                    {'subname': 'a', 'type': 'A', 'records': [], 'ttl': 3622},
-                    {'subname': 'b', 'type': 'AAAA', 'records': []},
-                ]),
+                    {"subname": "a", "type": "A", "records": [], "ttl": 3622},
+                    {"subname": "b", "type": "AAAA", "records": []},
+                ],
+            ),
             status.HTTP_200_OK,
             status.HTTP_200_OK,
             [],
             [],
         )
         )
 
 
     def test_bulk_patch_missing_invalid_fields_1(self):
     def test_bulk_patch_missing_invalid_fields_1(self):
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(self.my_empty_domain.name)):
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(self.my_empty_domain.name)
+        ):
             self.client.bulk_post_rr_sets(
             self.client.bulk_post_rr_sets(
                 domain_name=self.my_empty_domain.name,
                 domain_name=self.my_empty_domain.name,
                 payload=[
                 payload=[
-                    {'subname': '', 'ttl': 3650, 'type': 'TXT', 'records': ['"foo"']},
-                    {'subname': 'c.1', 'records': ['dead::beef'], 'type': 'AAAA', 'ttl': 3603},
-                    {'subname': 'd.1', 'ttl': 3650, 'type': 'AAAA', 'records': ['::1', '::2']},
-                ]
+                    {"subname": "", "ttl": 3650, "type": "TXT", "records": ['"foo"']},
+                    {
+                        "subname": "c.1",
+                        "records": ["dead::beef"],
+                        "type": "AAAA",
+                        "ttl": 3603,
+                    },
+                    {
+                        "subname": "d.1",
+                        "ttl": 3650,
+                        "type": "AAAA",
+                        "records": ["::1", "::2"],
+                    },
+                ],
             )
             )
         self.assertResponse(
         self.assertResponse(
             self.client.bulk_patch_rr_sets(
             self.client.bulk_patch_rr_sets(
                 domain_name=self.my_empty_domain.name,
                 domain_name=self.my_empty_domain.name,
                 payload=[
                 payload=[
-                    {'subname': 'a.1', 'records': ['dead::beef'], 'ttl': 3622},
-                    {'subname': 'b.1', 'ttl': -50, 'type': 'AAAA', 'records': ['dead::beef']},
-                    {'ttl': 3640, 'type': 'TXT', 'records': ['"bar"']},
-                    {'subname': 'c.1', 'records': ['dead::beef'], 'type': 'AAAA'},
-                    {'subname': 'd.1', 'ttl': 3650, 'type': 'AAAA'},
-                ]),
+                    {"subname": "a.1", "records": ["dead::beef"], "ttl": 3622},
+                    {
+                        "subname": "b.1",
+                        "ttl": -50,
+                        "type": "AAAA",
+                        "records": ["dead::beef"],
+                    },
+                    {"ttl": 3640, "type": "TXT", "records": ['"bar"']},
+                    {"subname": "c.1", "records": ["dead::beef"], "type": "AAAA"},
+                    {"subname": "d.1", "ttl": 3650, "type": "AAAA"},
+                ],
+            ),
             status.HTTP_400_BAD_REQUEST,
             status.HTTP_400_BAD_REQUEST,
             [
             [
-                {'type': ['This field is required.']},
-                {'ttl': [f'Ensure this value is greater than or equal to {settings.MINIMUM_TTL_DEFAULT}.']},
-                {'subname': ['This field is required.']},
+                {"type": ["This field is required."]},
+                {
+                    "ttl": [
+                        f"Ensure this value is greater than or equal to {settings.MINIMUM_TTL_DEFAULT}."
+                    ]
+                },
+                {"subname": ["This field is required."]},
                 {},
                 {},
                 {},
                 {},
-            ]
+            ],
         )
         )
 
 
     def test_bulk_patch_missing_invalid_fields_2(self):
     def test_bulk_patch_missing_invalid_fields_2(self):
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(self.my_empty_domain.name)):
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(self.my_empty_domain.name)
+        ):
             self.client.bulk_post_rr_sets(
             self.client.bulk_post_rr_sets(
                 domain_name=self.my_empty_domain.name,
                 domain_name=self.my_empty_domain.name,
                 payload=[
                 payload=[
-                    {'subname': '', 'ttl': 3650, 'type': 'TXT', 'records': ['"foo"']}
-                ]
+                    {"subname": "", "ttl": 3650, "type": "TXT", "records": ['"foo"']}
+                ],
             )
             )
         self.assertResponse(
         self.assertResponse(
             self.client.bulk_patch_rr_sets(
             self.client.bulk_patch_rr_sets(
                 domain_name=self.my_empty_domain.name,
                 domain_name=self.my_empty_domain.name,
                 payload=[
                 payload=[
-                    {'subname': 'a.1', 'records': ['dead::beef'], 'ttl': 3622},
-                    {'subname': 'b.1', 'ttl': -50, 'type': 'AAAA', 'records': ['dead::beef']},
-                    {'ttl': 3640, 'type': 'TXT', 'records': ['"bar"']},
-                    {'subname': 'c.1', 'records': ['dead::beef'], 'type': 'AAAA'},
-                    {'subname': 'd.1', 'ttl': 3650, 'type': 'AAAA'},
-                ]),
+                    {"subname": "a.1", "records": ["dead::beef"], "ttl": 3622},
+                    {
+                        "subname": "b.1",
+                        "ttl": -50,
+                        "type": "AAAA",
+                        "records": ["dead::beef"],
+                    },
+                    {"ttl": 3640, "type": "TXT", "records": ['"bar"']},
+                    {"subname": "c.1", "records": ["dead::beef"], "type": "AAAA"},
+                    {"subname": "d.1", "ttl": 3650, "type": "AAAA"},
+                ],
+            ),
             status.HTTP_400_BAD_REQUEST,
             status.HTTP_400_BAD_REQUEST,
             [
             [
-                {'type': ['This field is required.']},
-                {'ttl': [f'Ensure this value is greater than or equal to {settings.MINIMUM_TTL_DEFAULT}.']},
-                {'subname': ['This field is required.']},
-                {'ttl': ['This field is required.']},
-                {'records': ['This field is required.']},
-            ]
+                {"type": ["This field is required."]},
+                {
+                    "ttl": [
+                        f"Ensure this value is greater than or equal to {settings.MINIMUM_TTL_DEFAULT}."
+                    ]
+                },
+                {"subname": ["This field is required."]},
+                {"ttl": ["This field is required."]},
+                {"records": ["This field is required."]},
+            ],
         )
         )
 
 
     def test_bulk_put_partial(self):
     def test_bulk_put_partial(self):
         # Need all fields
         # Need all fields
         for domain in [self.my_empty_domain, self.bulk_domain]:
         for domain in [self.my_empty_domain, self.bulk_domain]:
-            response = self.client.bulk_put_rr_sets(domain_name=domain.name, payload=self.data_no_records)
+            response = self.client.bulk_put_rr_sets(
+                domain_name=domain.name, payload=self.data_no_records
+            )
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-            self.assertEqual(response.data, [{}, {'records': ['This field is required.']}])
+            self.assertEqual(
+                response.data, [{}, {"records": ["This field is required."]}]
+            )
 
 
-            response = self.client.bulk_put_rr_sets(domain_name=domain.name, payload=self.data_no_ttl)
+            response = self.client.bulk_put_rr_sets(
+                domain_name=domain.name, payload=self.data_no_ttl
+            )
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-            self.assertEqual(response.data, [{'ttl': ['This field is required.']}, {}])
+            self.assertEqual(response.data, [{"ttl": ["This field is required."]}, {}])
 
 
-            response = self.client.bulk_put_rr_sets(domain_name=domain.name, payload=self.data_no_records_no_ttl)
+            response = self.client.bulk_put_rr_sets(
+                domain_name=domain.name, payload=self.data_no_records_no_ttl
+            )
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-            self.assertEqual(response.data, [{},
-                                             {'ttl': ['This field is required.'],
-                                              'records': ['This field is required.']}])
+            self.assertEqual(
+                response.data,
+                [
+                    {},
+                    {
+                        "ttl": ["This field is required."],
+                        "records": ["This field is required."],
+                    },
+                ],
+            )
 
 
-            response = self.client.bulk_put_rr_sets(domain_name=domain.name, payload=self.data_no_subname)
+            response = self.client.bulk_put_rr_sets(
+                domain_name=domain.name, payload=self.data_no_subname
+            )
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-            self.assertEqual(response.data, [{'subname': ['This field is required.']}, {}])
+            self.assertEqual(
+                response.data, [{"subname": ["This field is required."]}, {}]
+            )
 
 
-            response = self.client.bulk_put_rr_sets(domain_name=domain.name, payload=self.data_no_subname_empty_records)
+            response = self.client.bulk_put_rr_sets(
+                domain_name=domain.name, payload=self.data_no_subname_empty_records
+            )
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-            self.assertEqual(response.data, [{'subname': ['This field is required.']}, {}])
+            self.assertEqual(
+                response.data, [{"subname": ["This field is required."]}, {}]
+            )
 
 
-            response = self.client.bulk_put_rr_sets(domain_name=domain.name, payload=self.data_no_type)
+            response = self.client.bulk_put_rr_sets(
+                domain_name=domain.name, payload=self.data_no_type
+            )
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-            self.assertEqual(response.data, [{}, {'type': ['This field is required.']}])
+            self.assertEqual(response.data, [{}, {"type": ["This field is required."]}])
 
 
     def test_bulk_put_does_not_accept_single_objects(self):
     def test_bulk_put_does_not_accept_single_objects(self):
-        response = self.client.bulk_put_rr_sets(domain_name=self.my_empty_domain.name, payload=self.data[0])
-        self.assertContains(response, 'Expected a list of items but got dict.', status_code=status.HTTP_400_BAD_REQUEST)
+        response = self.client.bulk_put_rr_sets(
+            domain_name=self.my_empty_domain.name, payload=self.data[0]
+        )
+        self.assertContains(
+            response,
+            "Expected a list of items but got dict.",
+            status_code=status.HTTP_400_BAD_REQUEST,
+        )
 
 
     def test_bulk_put_does_accept_empty_list(self):
     def test_bulk_put_does_accept_empty_list(self):
-        response = self.client.bulk_put_rr_sets(domain_name=self.my_empty_domain.name, payload=[])
+        response = self.client.bulk_put_rr_sets(
+            domain_name=self.my_empty_domain.name, payload=[]
+        )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
-        response = self.client.bulk_put_rr_sets(domain_name=self.my_rr_set_domain.name, payload=[])
+        response = self.client.bulk_put_rr_sets(
+            domain_name=self.my_rr_set_domain.name, payload=[]
+        )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
 
 
     def test_bulk_put_does_not_accept_empty_payload(self):
     def test_bulk_put_does_not_accept_empty_payload(self):
-        response = self.client.bulk_put_rr_sets(domain_name=self.my_empty_domain.name, payload=None)
-        self.assertContains(response, 'No data provided', status_code=status.HTTP_400_BAD_REQUEST)
+        response = self.client.bulk_put_rr_sets(
+            domain_name=self.my_empty_domain.name, payload=None
+        )
+        self.assertContains(
+            response, "No data provided", status_code=status.HTTP_400_BAD_REQUEST
+        )
 
 
     def test_bulk_put_does_not_accept_list_of_crap(self):
     def test_bulk_put_does_not_accept_list_of_crap(self):
-        response = self.client.bulk_put_rr_sets(domain_name=self.my_empty_domain.name, payload=['bla'])
-        self.assertContains(response, 'Expected a dictionary, but got str.', status_code=status.HTTP_400_BAD_REQUEST)
+        response = self.client.bulk_put_rr_sets(
+            domain_name=self.my_empty_domain.name, payload=["bla"]
+        )
+        self.assertContains(
+            response,
+            "Expected a dictionary, but got str.",
+            status_code=status.HTTP_400_BAD_REQUEST,
+        )
 
 
-        response = self.client.bulk_put_rr_sets(domain_name=self.my_empty_domain.name, payload=[42])
-        self.assertContains(response, 'Expected a dictionary, but got int.', status_code=status.HTTP_400_BAD_REQUEST)
+        response = self.client.bulk_put_rr_sets(
+            domain_name=self.my_empty_domain.name, payload=[42]
+        )
+        self.assertContains(
+            response,
+            "Expected a dictionary, but got int.",
+            status_code=status.HTTP_400_BAD_REQUEST,
+        )
 
 
     def test_bulk_put_does_not_accept_rrsets_with_nonstr_subname(self):
     def test_bulk_put_does_not_accept_rrsets_with_nonstr_subname(self):
-        payload = [{"subname": ["foobar"], "type": "A", "ttl": 3600, "records": ["1.2.3.4"]}]
-        response = self.client.bulk_put_rr_sets(domain_name=self.my_empty_domain.name, payload=payload)
-        self.assertContains(response, 'Expected a string, but got list.', status_code=status.HTTP_400_BAD_REQUEST)
+        payload = [
+            {"subname": ["foobar"], "type": "A", "ttl": 3600, "records": ["1.2.3.4"]}
+        ]
+        response = self.client.bulk_put_rr_sets(
+            domain_name=self.my_empty_domain.name, payload=payload
+        )
+        self.assertContains(
+            response,
+            "Expected a string, but got list.",
+            status_code=status.HTTP_400_BAD_REQUEST,
+        )
 
 
     def test_bulk_put_does_not_accept_rrsets_with_nonstr_type(self):
     def test_bulk_put_does_not_accept_rrsets_with_nonstr_type(self):
-        payload = [{"subname": "foobar", "type": ["A"], "ttl": 3600, "records": ["1.2.3.4"]}]
-        response = self.client.bulk_put_rr_sets(domain_name=self.my_empty_domain.name, payload=payload)
-        self.assertContains(response, 'Expected a string, but got list.', status_code=status.HTTP_400_BAD_REQUEST)
+        payload = [
+            {"subname": "foobar", "type": ["A"], "ttl": 3600, "records": ["1.2.3.4"]}
+        ]
+        response = self.client.bulk_put_rr_sets(
+            domain_name=self.my_empty_domain.name, payload=payload
+        )
+        self.assertContains(
+            response,
+            "Expected a string, but got list.",
+            status_code=status.HTTP_400_BAD_REQUEST,
+        )
 
 
     def test_bulk_put_full(self):
     def test_bulk_put_full(self):
         # Full PUT always works
         # Full PUT always works
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_empty_domain.name)):
-            response = self.client.bulk_put_rr_sets(domain_name=self.my_empty_domain.name, payload=self.data)
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(name=self.my_empty_domain.name)
+        ):
+            response = self.client.bulk_put_rr_sets(
+                domain_name=self.my_empty_domain.name, payload=self.data
+            )
             self.assertStatus(response, status.HTTP_200_OK)
             self.assertStatus(response, status.HTTP_200_OK)
 
 
         # Check that RRsets have been created
         # Check that RRsets have been created
@@ -412,27 +673,37 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
         self.assertRRSetsCount(response.data, self.data)
         self.assertRRSetsCount(response.data, self.data)
 
 
         # Do not expect any updates, but successful code when PUT'ing only existing RRsets
         # Do not expect any updates, but successful code when PUT'ing only existing RRsets
-        response = self.client.bulk_put_rr_sets(domain_name=self.bulk_domain.name, payload=self.data)
+        response = self.client.bulk_put_rr_sets(
+            domain_name=self.bulk_domain.name, payload=self.data
+        )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
 
 
     def test_bulk_put_invalid_records(self):
     def test_bulk_put_invalid_records(self):
         for records in [
         for records in [
-            'asfd',
-            ['1.1.1.1', '2.2.2.2', 123],
-            ['1.2.3.4', None],
-            [True, '1.1.1.1'],
-            dict(foobar='foobar', asdf='asdf'),
+            "asfd",
+            ["1.1.1.1", "2.2.2.2", 123],
+            ["1.2.3.4", None],
+            [True, "1.1.1.1"],
+            dict(foobar="foobar", asdf="asdf"),
         ]:
         ]:
-            payload = [{'subname': 'a.2', 'ttl': 3600, 'type': 'MX', 'records': records}]
-            response = self.client.bulk_put_rr_sets(domain_name=self.my_empty_domain.name, payload=payload)
+            payload = [
+                {"subname": "a.2", "ttl": 3600, "type": "MX", "records": records}
+            ]
+            response = self.client.bulk_put_rr_sets(
+                domain_name=self.my_empty_domain.name, payload=payload
+            )
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-            self.assertTrue('records' in response.data[0])
+            self.assertTrue("records" in response.data[0])
 
 
     def test_bulk_put_empty_records(self):
     def test_bulk_put_empty_records(self):
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.bulk_domain.name)):
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(name=self.bulk_domain.name)
+        ):
             self.assertStatus(
             self.assertStatus(
-                self.client.bulk_put_rr_sets(domain_name=self.bulk_domain.name, payload=self.data_empty_records),
-                status.HTTP_200_OK
+                self.client.bulk_put_rr_sets(
+                    domain_name=self.bulk_domain.name, payload=self.data_empty_records
+                ),
+                status.HTTP_200_OK,
             )
             )
 
 
     def test_bulk_duplicate_rrset(self):
     def test_bulk_duplicate_rrset(self):
@@ -442,19 +713,26 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
             self.client.bulk_put_rr_sets,
             self.client.bulk_put_rr_sets,
             self.client.bulk_post_rr_sets,
             self.client.bulk_post_rr_sets,
         ]:
         ]:
-            response = bulk_request_rr_sets(domain_name=self.my_empty_domain.name, payload=data)
+            response = bulk_request_rr_sets(
+                domain_name=self.my_empty_domain.name, payload=data
+            )
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
 
 
     def test_bulk_patch_or_post_failure_with_single_rrset(self):
     def test_bulk_patch_or_post_failure_with_single_rrset(self):
         for method in [self.client.bulk_patch_rr_sets, self.client.bulk_put_rr_sets]:
         for method in [self.client.bulk_patch_rr_sets, self.client.bulk_put_rr_sets]:
-            response = method(domain_name=self.my_empty_domain.name, payload=self.data[0])
-            self.assertContains(response, 'Expected a list of items but got dict.',
-                                status_code=status.HTTP_400_BAD_REQUEST)
+            response = method(
+                domain_name=self.my_empty_domain.name, payload=self.data[0]
+            )
+            self.assertContains(
+                response,
+                "Expected a list of items but got dict.",
+                status_code=status.HTTP_400_BAD_REQUEST,
+            )
 
 
     def test_bulk_delete_rrsets(self):
     def test_bulk_delete_rrsets(self):
         self.assertStatus(
         self.assertStatus(
             self.client.delete(
             self.client.delete(
-                self.reverse('v1:rrsets', name=self.my_empty_domain.name),
+                self.reverse("v1:rrsets", name=self.my_empty_domain.name),
                 data=None,
                 data=None,
             ),
             ),
             status.HTTP_405_METHOD_NOT_ALLOWED,
             status.HTTP_405_METHOD_NOT_ALLOWED,

+ 64 - 27
api/desecapi/tests/test_stop_abuse.py

@@ -6,27 +6,44 @@ from desecapi.tests.base import DomainOwnerTestCase
 
 
 
 
 class StopAbuseCommandTest(DomainOwnerTestCase):
 class StopAbuseCommandTest(DomainOwnerTestCase):
-
     @classmethod
     @classmethod
     def setUpTestDataWithPdns(cls):
     def setUpTestDataWithPdns(cls):
         super().setUpTestDataWithPdns()
         super().setUpTestDataWithPdns()
-        cls.create_rr_set(cls.my_domains[1], ['127.0.0.1', '127.0.1.1'], type='A', ttl=123)
-        cls.create_rr_set(cls.other_domains[1], ['40.1.1.1', '40.2.2.2'], type='A', ttl=456)
+        cls.create_rr_set(
+            cls.my_domains[1], ["127.0.0.1", "127.0.1.1"], type="A", ttl=123
+        )
+        cls.create_rr_set(
+            cls.other_domains[1], ["40.1.1.1", "40.2.2.2"], type="A", ttl=456
+        )
         for d in cls.my_domains + cls.other_domains:
         for d in cls.my_domains + cls.other_domains:
-            cls.create_rr_set(d, ['ns1.example.', 'ns2.example.'], type='NS', ttl=456)
-            cls.create_rr_set(d, ['ns1.example.', 'ns2.example.'], type='NS', ttl=456, subname='subname')
-            cls.create_rr_set(d, ['"foo"'], type='TXT', ttl=456)
+            cls.create_rr_set(d, ["ns1.example.", "ns2.example."], type="NS", ttl=456)
+            cls.create_rr_set(
+                d,
+                ["ns1.example.", "ns2.example."],
+                type="NS",
+                ttl=456,
+                subname="subname",
+            )
+            cls.create_rr_set(d, ['"foo"'], type="TXT", ttl=456)
 
 
     def test_noop(self):
     def test_noop(self):
         # test implicit by absence assertPdnsRequests
         # test implicit by absence assertPdnsRequests
-        management.call_command('stop-abuse')
+        management.call_command("stop-abuse")
 
 
     def test_remove_rrsets_by_domain_name(self):
     def test_remove_rrsets_by_domain_name(self):
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_domain.name)):
-            management.call_command('stop-abuse', self.my_domain)
-        self.assertEqual(models.RRset.objects.filter(domain__name=self.my_domain.name).count(), 1)  # only NS left
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(name=self.my_domain.name)
+        ):
+            management.call_command("stop-abuse", self.my_domain)
+        self.assertEqual(
+            models.RRset.objects.filter(domain__name=self.my_domain.name).count(), 1
+        )  # only NS left
         self.assertEqual(
         self.assertEqual(
-            set(models.RR.objects.filter(rrset__domain__name=self.my_domain.name).values_list('content', flat=True)),
+            set(
+                models.RR.objects.filter(
+                    rrset__domain__name=self.my_domain.name
+                ).values_list("content", flat=True)
+            ),
             set(settings.DEFAULT_NS),
             set(settings.DEFAULT_NS),
         )
         )
 
 
@@ -35,16 +52,24 @@ class StopAbuseCommandTest(DomainOwnerTestCase):
             *[self.requests_desec_rr_sets_update(name=d.name) for d in self.my_domains],
             *[self.requests_desec_rr_sets_update(name=d.name) for d in self.my_domains],
             expect_order=False,
             expect_order=False,
         ):
         ):
-            management.call_command('stop-abuse', self.owner.email)
-        self.assertEqual(models.RRset.objects.filter(domain__name=self.my_domain.name).count(), 1)  # only NS left
+            management.call_command("stop-abuse", self.owner.email)
         self.assertEqual(
         self.assertEqual(
-            set(models.RR.objects.filter(rrset__domain__name=self.my_domain.name).values_list('content', flat=True)),
+            models.RRset.objects.filter(domain__name=self.my_domain.name).count(), 1
+        )  # only NS left
+        self.assertEqual(
+            set(
+                models.RR.objects.filter(
+                    rrset__domain__name=self.my_domain.name
+                ).values_list("content", flat=True)
+            ),
             set(settings.DEFAULT_NS),
             set(settings.DEFAULT_NS),
         )
         )
 
 
     def test_disable_user_by_domain_name(self):
     def test_disable_user_by_domain_name(self):
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_domain.name)):
-            management.call_command('stop-abuse', self.my_domain)
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(name=self.my_domain.name)
+        ):
+            management.call_command("stop-abuse", self.my_domain)
         self.owner.refresh_from_db()
         self.owner.refresh_from_db()
         self.assertEqual(self.owner.is_active, False)
         self.assertEqual(self.owner.is_active, False)
 
 
@@ -53,22 +78,28 @@ class StopAbuseCommandTest(DomainOwnerTestCase):
             *[self.requests_desec_rr_sets_update(name=d.name) for d in self.my_domains],
             *[self.requests_desec_rr_sets_update(name=d.name) for d in self.my_domains],
             expect_order=False,
             expect_order=False,
         ):
         ):
-            management.call_command('stop-abuse', self.owner.email)
+            management.call_command("stop-abuse", self.owner.email)
         self.owner.refresh_from_db()
         self.owner.refresh_from_db()
         self.assertEqual(self.owner.is_active, False)
         self.assertEqual(self.owner.is_active, False)
 
 
     def test_keep_other_owned_domains_name(self):
     def test_keep_other_owned_domains_name(self):
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_domain.name)):
-            management.call_command('stop-abuse', self.my_domain)
-        self.assertGreater(models.RRset.objects.filter(domain__name=self.my_domains[1].name).count(), 1)
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(name=self.my_domain.name)
+        ):
+            management.call_command("stop-abuse", self.my_domain)
+        self.assertGreater(
+            models.RRset.objects.filter(domain__name=self.my_domains[1].name).count(), 1
+        )
 
 
     def test_dont_keep_other_owned_domains_email(self):
     def test_dont_keep_other_owned_domains_email(self):
         with self.assertPdnsRequests(
         with self.assertPdnsRequests(
             *[self.requests_desec_rr_sets_update(name=d.name) for d in self.my_domains],
             *[self.requests_desec_rr_sets_update(name=d.name) for d in self.my_domains],
             expect_order=False,
             expect_order=False,
         ):
         ):
-            management.call_command('stop-abuse', self.owner.email)
-        self.assertEqual(models.RRset.objects.filter(domain__name=self.my_domains[1].name).count(), 1)
+            management.call_command("stop-abuse", self.owner.email)
+        self.assertEqual(
+            models.RRset.objects.filter(domain__name=self.my_domains[1].name).count(), 1
+        )
 
 
     def test_only_disable_owner(self):
     def test_only_disable_owner(self):
         with self.assertPdnsRequests(
         with self.assertPdnsRequests(
@@ -76,7 +107,7 @@ class StopAbuseCommandTest(DomainOwnerTestCase):
             self.requests_desec_rr_sets_update(name=self.my_domains[1].name),
             self.requests_desec_rr_sets_update(name=self.my_domains[1].name),
             expect_order=False,
             expect_order=False,
         ):
         ):
-            management.call_command('stop-abuse', self.my_domain, self.owner.email)
+            management.call_command("stop-abuse", self.my_domain, self.owner.email)
         self.my_domain.owner.refresh_from_db()
         self.my_domain.owner.refresh_from_db()
         self.other_domain.owner.refresh_from_db()
         self.other_domain.owner.refresh_from_db()
         self.assertEqual(self.my_domain.owner.is_active, False)
         self.assertEqual(self.my_domain.owner.is_active, False)
@@ -88,7 +119,7 @@ class StopAbuseCommandTest(DomainOwnerTestCase):
             self.requests_desec_rr_sets_update(name=self.other_domain.name),
             self.requests_desec_rr_sets_update(name=self.other_domain.name),
             expect_order=False,
             expect_order=False,
         ):
         ):
-            management.call_command('stop-abuse', self.my_domain, self.other_domain)
+            management.call_command("stop-abuse", self.my_domain, self.other_domain)
         self.my_domain.owner.refresh_from_db()
         self.my_domain.owner.refresh_from_db()
         self.other_domain.owner.refresh_from_db()
         self.other_domain.owner.refresh_from_db()
         self.assertEqual(self.my_domain.owner.is_active, False)
         self.assertEqual(self.my_domain.owner.is_active, False)
@@ -96,12 +127,18 @@ class StopAbuseCommandTest(DomainOwnerTestCase):
 
 
     def test_disable_owners_by_email(self):
     def test_disable_owners_by_email(self):
         with self.assertPdnsRequests(
         with self.assertPdnsRequests(
-            *[self.requests_desec_rr_sets_update(name=d.name) for d in self.my_domains + self.other_domains],
+            *[
+                self.requests_desec_rr_sets_update(name=d.name)
+                for d in self.my_domains + self.other_domains
+            ],
             expect_order=False,
             expect_order=False,
         ):
         ):
-            management.call_command('stop-abuse', self.owner.email, *[d.owner.email for d in self.other_domains])
+            management.call_command(
+                "stop-abuse",
+                self.owner.email,
+                *[d.owner.email for d in self.other_domains],
+            )
         self.my_domain.owner.refresh_from_db()
         self.my_domain.owner.refresh_from_db()
         self.other_domain.owner.refresh_from_db()
         self.other_domain.owner.refresh_from_db()
         self.assertEqual(self.my_domain.owner.is_active, False)
         self.assertEqual(self.my_domain.owner.is_active, False)
         self.assertEqual(self.other_domain.owner.is_active, False)
         self.assertEqual(self.other_domain.owner.is_active, False)
-

+ 29 - 14
api/desecapi/tests/test_throttling.py

@@ -10,27 +10,33 @@ from rest_framework.test import APIRequestFactory
 
 
 
 
 def override_rates(rates):
 def override_rates(rates):
-    return override_settings(REST_FRAMEWORK={'DEFAULT_THROTTLE_CLASSES': ['desecapi.throttling.ScopedRatesThrottle'],
-                                             'DEFAULT_THROTTLE_RATES': {'test_scope': rates}})
+    return override_settings(
+        REST_FRAMEWORK={
+            "DEFAULT_THROTTLE_CLASSES": ["desecapi.throttling.ScopedRatesThrottle"],
+            "DEFAULT_THROTTLE_RATES": {"test_scope": rates},
+        }
+    )
 
 
 
 
 class MockView(APIView):
 class MockView(APIView):
-    throttle_scope = 'test_scope'
+    throttle_scope = "test_scope"
 
 
     @property
     @property
     def throttle_classes(self):
     def throttle_classes(self):
         # Need to import here so that the module is only loaded once the settings override is in effect
         # Need to import here so that the module is only loaded once the settings override is in effect
         from desecapi.throttling import ScopedRatesThrottle
         from desecapi.throttling import ScopedRatesThrottle
+
         return (ScopedRatesThrottle,)
         return (ScopedRatesThrottle,)
 
 
     def get(self, request):
     def get(self, request):
-        return Response('foo')
+        return Response("foo")
 
 
 
 
 class ThrottlingTestCase(TestCase):
 class ThrottlingTestCase(TestCase):
     """
     """
     Based on DRF's test_throttling.py.
     Based on DRF's test_throttling.py.
     """
     """
+
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
         self.factory = APIRequestFactory()
         self.factory = APIRequestFactory()
@@ -41,17 +47,24 @@ class ThrottlingTestCase(TestCase):
             sum_delay = 0
             sum_delay = 0
             for delay, count, max_wait in counts:
             for delay, count, max_wait in counts:
                 sum_delay += delay
                 sum_delay += delay
-                with mock.patch('desecapi.throttling.ScopedRatesThrottle.timer', return_value=time.time() + sum_delay):
+                with mock.patch(
+                    "desecapi.throttling.ScopedRatesThrottle.timer",
+                    return_value=time.time() + sum_delay,
+                ):
                     for _ in range(count):
                     for _ in range(count):
                         response = view(request)
                         response = view(request)
                         self.assertEqual(response.status_code, status.HTTP_200_OK)
                         self.assertEqual(response.status_code, status.HTTP_200_OK)
 
 
                     response = view(request)
                     response = view(request)
-                    self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)
-                    self.assertTrue(max_wait - 1 <= float(response['Retry-After']) <= max_wait)
+                    self.assertEqual(
+                        response.status_code, status.HTTP_429_TOO_MANY_REQUESTS
+                    )
+                    self.assertTrue(
+                        max_wait - 1 <= float(response["Retry-After"]) <= max_wait
+                    )
 
 
         cache.clear()
         cache.clear()
-        request = self.factory.get('/')
+        request = self.factory.get("/")
         with override_rates(rates):
         with override_rates(rates):
             do_test()
             do_test()
             if buckets is not None:
             if buckets is not None:
@@ -60,19 +73,21 @@ class ThrottlingTestCase(TestCase):
                     do_test()
                     do_test()
 
 
     def test_requests_are_throttled_4sec(self):
     def test_requests_are_throttled_4sec(self):
-        self._test_requests_are_throttled(['4/sec'], [(0, 4, 1), (1, 4, 1)])
+        self._test_requests_are_throttled(["4/sec"], [(0, 4, 1), (1, 4, 1)])
 
 
     def test_requests_are_throttled_4min(self):
     def test_requests_are_throttled_4min(self):
-        self._test_requests_are_throttled(['4/min'], [(0, 4, 60)])
+        self._test_requests_are_throttled(["4/min"], [(0, 4, 60)])
 
 
     def test_requests_are_throttled_multiple(self):
     def test_requests_are_throttled_multiple(self):
-        self._test_requests_are_throttled(['5/s', '4/day'], [(0, 4, 86400)])
-        self._test_requests_are_throttled(['4/s', '5/day'], [(0, 4, 1)])
+        self._test_requests_are_throttled(["5/s", "4/day"], [(0, 4, 86400)])
+        self._test_requests_are_throttled(["4/s", "5/day"], [(0, 4, 1)])
 
 
     def test_requests_are_throttled_multiple_cascade(self):
     def test_requests_are_throttled_multiple_cascade(self):
         # We test that we can do 4 requests in the first second and only 2 in the second second
         # We test that we can do 4 requests in the first second and only 2 in the second second
-        self._test_requests_are_throttled(['4/s', '6/day'], [(0, 4, 1), (1, 2, 86400)])
+        self._test_requests_are_throttled(["4/s", "6/day"], [(0, 4, 1), (1, 2, 86400)])
 
 
     def test_requests_are_throttled_multiple_cascade_with_buckets(self):
     def test_requests_are_throttled_multiple_cascade_with_buckets(self):
         # We test that we can do 4 requests in the first second and only 2 in the second second
         # We test that we can do 4 requests in the first second and only 2 in the second second
-        self._test_requests_are_throttled(['4/s', '6/day'], [(0, 4, 1), (1, 2, 86400)], buckets=['foo', 'bar'])
+        self._test_requests_are_throttled(
+            ["4/s", "6/day"], [(0, 4, 1), (1, 2, 86400)], buckets=["foo", "bar"]
+        )

+ 156 - 61
api/desecapi/tests/test_token_domain_policy.py

@@ -12,16 +12,20 @@ from desecapi.tests.base import DomainOwnerTestCase
 class TokenDomainPolicyClient(APIClient):
 class TokenDomainPolicyClient(APIClient):
     def _request(self, method, url, *, using, **kwargs):
     def _request(self, method, url, *, using, **kwargs):
         if using is not None:
         if using is not None:
-            kwargs.update(HTTP_AUTHORIZATION=f'Token {using.plain}')
+            kwargs.update(HTTP_AUTHORIZATION=f"Token {using.plain}")
         return method(url, **kwargs)
         return method(url, **kwargs)
 
 
     def _request_policy(self, method, target, *, using, domain, **kwargs):
     def _request_policy(self, method, target, *, using, domain, **kwargs):
-        domain = domain or 'default'
-        url = DomainOwnerTestCase.reverse('v1:token_domain_policies-detail', token_id=target.id, domain__name=domain)
+        domain = domain or "default"
+        url = DomainOwnerTestCase.reverse(
+            "v1:token_domain_policies-detail", token_id=target.id, domain__name=domain
+        )
         return self._request(method, url, using=using, **kwargs)
         return self._request(method, url, using=using, **kwargs)
 
 
     def _request_policies(self, method, target, *, using, **kwargs):
     def _request_policies(self, method, target, *, using, **kwargs):
-        url = DomainOwnerTestCase.reverse('v1:token_domain_policies-list', token_id=target.id)
+        url = DomainOwnerTestCase.reverse(
+            "v1:token_domain_policies-list", token_id=target.id
+        )
         return self._request(method, url, using=using, **kwargs)
         return self._request(method, url, using=using, **kwargs)
 
 
     def list_policies(self, target, *, using):
     def list_policies(self, target, *, using):
@@ -34,7 +38,9 @@ class TokenDomainPolicyClient(APIClient):
         return self._request_policy(self.get, target, using=using, domain=domain)
         return self._request_policy(self.get, target, using=using, domain=domain)
 
 
     def patch_policy(self, target, *, using, domain, **kwargs):
     def patch_policy(self, target, *, using, domain, **kwargs):
-        return self._request_policy(self.patch, target, using=using, domain=domain, **kwargs)
+        return self._request_policy(
+            self.patch, target, using=using, domain=domain, **kwargs
+        )
 
 
     def delete_policy(self, target, *, using, domain):
     def delete_policy(self, target, *, using, domain):
         return self._request_policy(self.delete, target, using=using, domain=domain)
         return self._request_policy(self.delete, target, using=using, domain=domain)
@@ -53,9 +59,13 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
     def test_policy_lifecycle_without_management_permission(self):
     def test_policy_lifecycle_without_management_permission(self):
         # Prepare (with management token)
         # Prepare (with management token)
         data = dict(domain=None, perm_rrsets=True)
         data = dict(domain=None, perm_rrsets=True)
-        response = self.client.create_policy(self.token, using=self.token_manage, data=data)
+        response = self.client.create_policy(
+            self.token, using=self.token_manage, data=data
+        )
         self.assertStatus(response, status.HTTP_201_CREATED)
         self.assertStatus(response, status.HTTP_201_CREATED)
-        response = self.client.create_policy(self.token_manage, using=self.token_manage, data=data)
+        response = self.client.create_policy(
+            self.token_manage, using=self.token_manage, data=data
+        )
         self.assertStatus(response, status.HTTP_201_CREATED)
         self.assertStatus(response, status.HTTP_201_CREATED)
 
 
         # Self-inspection is fine
         # Self-inspection is fine
@@ -75,7 +85,9 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
         self.assertStatus(response, status.HTTP_403_FORBIDDEN)
         self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
 
         ## Get
         ## Get
-        response = self.client.get_policy(self.token_manage, using=self.token, domain=None)
+        response = self.client.get_policy(
+            self.token_manage, using=self.token, domain=None
+        )
         self.assertStatus(response, status.HTTP_403_FORBIDDEN)
         self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
 
         # Write operations forbidden (self and other)
         # Write operations forbidden (self and other)
@@ -85,8 +97,12 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
             self.assertStatus(response, status.HTTP_403_FORBIDDEN)
             self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
 
             # Change
             # Change
-            data = dict(domain=self.my_domains[1].name, perm_dyndns=False, perm_rrsets=True)
-            response = self.client.patch_policy(target, using=self.token, domain=self.my_domains[0].name, data=data)
+            data = dict(
+                domain=self.my_domains[1].name, perm_dyndns=False, perm_rrsets=True
+            )
+            response = self.client.patch_policy(
+                target, using=self.token, domain=self.my_domains[0].name, data=data
+            )
             self.assertStatus(response, status.HTTP_403_FORBIDDEN)
             self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
 
             # Delete
             # Delete
@@ -105,14 +121,19 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
         ## without required field
         ## without required field
         response = self.client.create_policy(self.token, using=self.token_manage)
         response = self.client.create_policy(self.token, using=self.token_manage)
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-        self.assertEqual(response.data['domain'], ['This field is required.'])
+        self.assertEqual(response.data["domain"], ["This field is required."])
 
 
         ## without a default policy
         ## without a default policy
         data = dict(domain=self.my_domains[0].name)
         data = dict(domain=self.my_domains[0].name)
         with transaction.atomic():
         with transaction.atomic():
-            response = self.client.create_policy(self.token, using=self.token_manage, data=data)
+            response = self.client.create_policy(
+                self.token, using=self.token_manage, data=data
+            )
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-        self.assertEqual(response.data['domain'], ['Policy precedence: The first policy must be the default policy.'])
+        self.assertEqual(
+            response.data["domain"],
+            ["Policy precedence: The first policy must be the default policy."],
+        )
 
 
         # List: still empty
         # List: still empty
         response = self.client.list_policies(self.token, using=self.token_manage)
         response = self.client.list_policies(self.token, using=self.token_manage)
@@ -122,38 +143,53 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
         # Create
         # Create
         ## default policy
         ## default policy
         data = dict(domain=None, perm_rrsets=True)
         data = dict(domain=None, perm_rrsets=True)
-        response = self.client.create_policy(self.token, using=self.token_manage, data=data)
+        response = self.client.create_policy(
+            self.token, using=self.token_manage, data=data
+        )
         self.assertStatus(response, status.HTTP_201_CREATED)
         self.assertStatus(response, status.HTTP_201_CREATED)
 
 
         ## can't create another default policy
         ## can't create another default policy
         with transaction.atomic():
         with transaction.atomic():
-            response = self.client.create_policy(self.token, using=self.token_manage, data=dict(domain=None))
+            response = self.client.create_policy(
+                self.token, using=self.token_manage, data=dict(domain=None)
+            )
         self.assertStatus(response, status.HTTP_409_CONFLICT)
         self.assertStatus(response, status.HTTP_409_CONFLICT)
 
 
         ## verify object creation
         ## verify object creation
-        response = self.client.get_policy(self.token, using=self.token_manage, domain=None)
+        response = self.client.get_policy(
+            self.token, using=self.token_manage, domain=None
+        )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertEqual(response.data, self.default_data | data)
         self.assertEqual(response.data, self.default_data | data)
 
 
         ## can't create policy for other user's domain
         ## can't create policy for other user's domain
         data = dict(domain=self.other_domain.name, perm_dyndns=True, perm_rrsets=True)
         data = dict(domain=self.other_domain.name, perm_dyndns=True, perm_rrsets=True)
-        response = self.client.create_policy(self.token, using=self.token_manage, data=data)
+        response = self.client.create_policy(
+            self.token, using=self.token_manage, data=data
+        )
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-        self.assertEqual(response.data['domain'][0].code, 'does_not_exist')
+        self.assertEqual(response.data["domain"][0].code, "does_not_exist")
 
 
         ## another policy
         ## another policy
         data = dict(domain=self.my_domains[0].name, perm_dyndns=True)
         data = dict(domain=self.my_domains[0].name, perm_dyndns=True)
-        response = self.client.create_policy(self.token, using=self.token_manage, data=data)
+        response = self.client.create_policy(
+            self.token, using=self.token_manage, data=data
+        )
         self.assertStatus(response, status.HTTP_201_CREATED)
         self.assertStatus(response, status.HTTP_201_CREATED)
 
 
         ## can't create policy for the same domain
         ## can't create policy for the same domain
         with transaction.atomic():
         with transaction.atomic():
-            response = self.client.create_policy(self.token, using=self.token_manage,
-                                                 data=dict(domain=self.my_domains[0].name, perm_dyndns=False))
+            response = self.client.create_policy(
+                self.token,
+                using=self.token_manage,
+                data=dict(domain=self.my_domains[0].name, perm_dyndns=False),
+            )
         self.assertStatus(response, status.HTTP_409_CONFLICT)
         self.assertStatus(response, status.HTTP_409_CONFLICT)
 
 
         ## verify object creation
         ## verify object creation
-        response = self.client.get_policy(self.token, using=self.token_manage, domain=self.my_domains[0].name)
+        response = self.client.get_policy(
+            self.token, using=self.token_manage, domain=self.my_domains[0].name
+        )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertEqual(response.data, self.default_data | data)
         self.assertEqual(response.data, self.default_data | data)
 
 
@@ -165,56 +201,90 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
         # Change
         # Change
         ## all fields of a policy
         ## all fields of a policy
         data = dict(domain=self.my_domains[1].name, perm_dyndns=False, perm_rrsets=True)
         data = dict(domain=self.my_domains[1].name, perm_dyndns=False, perm_rrsets=True)
-        response = self.client.patch_policy(self.token, using=self.token_manage, domain=self.my_domains[0].name,
-                                            data=data)
+        response = self.client.patch_policy(
+            self.token,
+            using=self.token_manage,
+            domain=self.my_domains[0].name,
+            data=data,
+        )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertEqual(response.data, self.default_data | data)
         self.assertEqual(response.data, self.default_data | data)
 
 
         ## verify modification
         ## verify modification
-        response = self.client.get_policy(self.token, using=self.token_manage, domain=self.my_domains[1].name)
+        response = self.client.get_policy(
+            self.token, using=self.token_manage, domain=self.my_domains[1].name
+        )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertEqual(response.data, self.default_data | data)
         self.assertEqual(response.data, self.default_data | data)
 
 
         ## verify that policy for former domain is gone
         ## verify that policy for former domain is gone
-        response = self.client.get_policy(self.token, using=self.token_manage, domain=self.my_domains[0].name)
+        response = self.client.get_policy(
+            self.token, using=self.token_manage, domain=self.my_domains[0].name
+        )
         self.assertStatus(response, status.HTTP_404_NOT_FOUND)
         self.assertStatus(response, status.HTTP_404_NOT_FOUND)
 
 
         ## verify that the default policy can't be changed to a non-default policy
         ## verify that the default policy can't be changed to a non-default policy
         with transaction.atomic():
         with transaction.atomic():
-            response = self.client.patch_policy(self.token, using=self.token_manage, domain=None, data=data)
+            response = self.client.patch_policy(
+                self.token, using=self.token_manage, domain=None, data=data
+            )
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-        self.assertEqual(response.data,
-                         {'domain': ['Policy precedence: Cannot disable default policy when others exist.']})
+        self.assertEqual(
+            response.data,
+            {
+                "domain": [
+                    "Policy precedence: Cannot disable default policy when others exist."
+                ]
+            },
+        )
 
 
         ## partially modify the default policy
         ## partially modify the default policy
         data = dict(perm_dyndns=True)
         data = dict(perm_dyndns=True)
-        response = self.client.patch_policy(self.token, using=self.token_manage, domain=None, data=data)
+        response = self.client.patch_policy(
+            self.token, using=self.token_manage, domain=None, data=data
+        )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
-        self.assertEqual(response.data, {'domain': None, 'perm_rrsets': True} | data)
+        self.assertEqual(response.data, {"domain": None, "perm_rrsets": True} | data)
 
 
         # Delete
         # Delete
         ## can't delete default policy while others exist
         ## can't delete default policy while others exist
         with transaction.atomic():
         with transaction.atomic():
-            response = self.client.delete_policy(self.token, using=self.token_manage, domain=None)
+            response = self.client.delete_policy(
+                self.token, using=self.token_manage, domain=None
+            )
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-        self.assertEqual(response.data,
-                         {'domain': ["Policy precedence: Can't delete default policy when there exist others."]})
+        self.assertEqual(
+            response.data,
+            {
+                "domain": [
+                    "Policy precedence: Can't delete default policy when there exist others."
+                ]
+            },
+        )
 
 
         ## delete other policy
         ## delete other policy
-        response = self.client.delete_policy(self.token, using=self.token_manage, domain=self.my_domains[1].name)
+        response = self.client.delete_policy(
+            self.token, using=self.token_manage, domain=self.my_domains[1].name
+        )
         self.assertStatus(response, status.HTTP_204_NO_CONTENT)
         self.assertStatus(response, status.HTTP_204_NO_CONTENT)
 
 
         ## delete default policy
         ## delete default policy
-        response = self.client.delete_policy(self.token, using=self.token_manage, domain=None)
+        response = self.client.delete_policy(
+            self.token, using=self.token_manage, domain=None
+        )
         self.assertStatus(response, status.HTTP_204_NO_CONTENT)
         self.assertStatus(response, status.HTTP_204_NO_CONTENT)
 
 
         ## idempotence: delete a non-existing policy
         ## idempotence: delete a non-existing policy
-        response = self.client.delete_policy(self.token, using=self.token_manage, domain=self.my_domains[0].name)
+        response = self.client.delete_policy(
+            self.token, using=self.token_manage, domain=self.my_domains[0].name
+        )
         self.assertStatus(response, status.HTTP_204_NO_CONTENT)
         self.assertStatus(response, status.HTTP_204_NO_CONTENT)
 
 
         ## verify that policies are gone
         ## verify that policies are gone
         for domain in [None, self.my_domains[0].name, self.my_domains[1].name]:
         for domain in [None, self.my_domains[0].name, self.my_domains[1].name]:
-            response = self.client.get_policy(self.token, using=self.token_manage, domain=domain)
+            response = self.client.get_policy(
+                self.token, using=self.token_manage, domain=domain
+            )
             self.assertStatus(response, status.HTTP_404_NOT_FOUND)
             self.assertStatus(response, status.HTTP_404_NOT_FOUND)
 
 
         # List: empty again
         # List: empty again
@@ -233,27 +303,31 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
             responses = []
             responses = []
             if value:
             if value:
                 pdns_name = self._normalize_name(name).lower()
                 pdns_name = self._normalize_name(name).lower()
-                cm = self.assertPdnsNoRequestsBut(self.request_pdns_zone_update(name=pdns_name),
-                                                  self.request_pdns_zone_axfr(name=pdns_name))
+                cm = self.assertPdnsNoRequestsBut(
+                    self.request_pdns_zone_update(name=pdns_name),
+                    self.request_pdns_zone_axfr(name=pdns_name),
+                )
             else:
             else:
                 cm = nullcontext()
                 cm = nullcontext()
 
 
-            if perm == 'perm_dyndns':
-                data = {'username': name, 'password': self.token.plain}
+            if perm == "perm_dyndns":
+                data = {"username": name, "password": self.token.plain}
                 with cm:
                 with cm:
-                    responses.append(self.client.get(self.reverse('v1:dyndns12update'), data))
+                    responses.append(
+                        self.client.get(self.reverse("v1:dyndns12update"), data)
+                    )
                 return responses
                 return responses
 
 
-            if perm == 'perm_rrsets':
-                url_detail = self.reverse('v1:rrset@', name=name, subname='', type='A')
-                url_list = self.reverse('v1:rrsets', name=name)
+            if perm == "perm_rrsets":
+                url_detail = self.reverse("v1:rrset@", name=name, subname="", type="A")
+                url_list = self.reverse("v1:rrsets", name=name)
 
 
                 responses.append(self.client.get(url_list, **kwargs))
                 responses.append(self.client.get(url_list, **kwargs))
                 responses.append(self.client.patch(url_list, [], **kwargs))
                 responses.append(self.client.patch(url_list, [], **kwargs))
                 responses.append(self.client.put(url_list, [], **kwargs))
                 responses.append(self.client.put(url_list, [], **kwargs))
                 responses.append(self.client.post(url_list, [], **kwargs))
                 responses.append(self.client.post(url_list, [], **kwargs))
 
 
-                data = {'subname': '', 'type': 'A', 'ttl': 3600, 'records': ['1.2.3.4']}
+                data = {"subname": "", "type": "A", "ttl": 3600, "records": ["1.2.3.4"]}
                 with cm:
                 with cm:
                     responses += [
                     responses += [
                         self.client.delete(url_detail, **kwargs),
                         self.client.delete(url_detail, **kwargs),
@@ -264,30 +338,40 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
                     ]
                     ]
                 return responses
                 return responses
 
 
-            raise ValueError(f'Unexpected permission: {perm}')
+            raise ValueError(f"Unexpected permission: {perm}")
 
 
         # Create
         # Create
         ## default policy
         ## default policy
         data = dict(domain=None)
         data = dict(domain=None)
-        response = self.client.create_policy(self.token, using=self.token_manage, data=data)
+        response = self.client.create_policy(
+            self.token, using=self.token_manage, data=data
+        )
         self.assertStatus(response, status.HTTP_201_CREATED)
         self.assertStatus(response, status.HTTP_201_CREATED)
 
 
         ## another policy
         ## another policy
         data = dict(domain=self.my_domains[0].name)
         data = dict(domain=self.my_domains[0].name)
-        response = self.client.create_policy(self.token, using=self.token_manage, data=data)
+        response = self.client.create_policy(
+            self.token, using=self.token_manage, data=data
+        )
         self.assertStatus(response, status.HTTP_201_CREATED)
         self.assertStatus(response, status.HTTP_201_CREATED)
 
 
         ## verify object creation
         ## verify object creation
-        response = self.client.get_policy(self.token, using=self.token_manage, domain=self.my_domains[0].name)
+        response = self.client.get_policy(
+            self.token, using=self.token_manage, domain=self.my_domains[0].name
+        )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertEqual(response.data, self.default_data | data)
         self.assertEqual(response.data, self.default_data | data)
 
 
         policies = {
         policies = {
-            self.my_domains[0]: self.token.tokendomainpolicy_set.get(domain__isnull=False),
-            self.my_domains[1]: self.token.tokendomainpolicy_set.get(domain__isnull=True),
+            self.my_domains[0]: self.token.tokendomainpolicy_set.get(
+                domain__isnull=False
+            ),
+            self.my_domains[1]: self.token.tokendomainpolicy_set.get(
+                domain__isnull=True
+            ),
         }
         }
 
 
-        kwargs = dict(HTTP_AUTHORIZATION=f'Token {self.token.plain}')
+        kwargs = dict(HTTP_AUTHORIZATION=f"Token {self.token.plain}")
 
 
         # For each permission type
         # For each permission type
         for perm in self.default_data.keys():
         for perm in self.default_data.keys():
@@ -302,19 +386,23 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
                     policy.save()
                     policy.save()
 
 
                     # Perform requests that test this permission and inspect responses
                     # Perform requests that test this permission and inspect responses
-                    for response in _perform_requests(domain.name, perm, value, **kwargs):
+                    for response in _perform_requests(
+                        domain.name, perm, value, **kwargs
+                    ):
                         if value:
                         if value:
                             self.assertIn(response.status_code, range(200, 300))
                             self.assertIn(response.status_code, range(200, 300))
                         else:
                         else:
                             self.assertStatus(response, status.HTTP_403_FORBIDDEN)
                             self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
 
                     # Can't create domain
                     # Can't create domain
-                    data = {'name': self.random_domain_name()}
-                    response = self.client.post(self.reverse('v1:domain-list'), data, **kwargs)
+                    data = {"name": self.random_domain_name()}
+                    response = self.client.post(
+                        self.reverse("v1:domain-list"), data, **kwargs
+                    )
                     self.assertStatus(response, status.HTTP_403_FORBIDDEN)
                     self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
 
                     # Can't access account details
                     # Can't access account details
-                    response = self.client.get(self.reverse('v1:account'), **kwargs)
+                    response = self.client.get(self.reverse("v1:account"), **kwargs)
                     self.assertStatus(response, status.HTTP_403_FORBIDDEN)
                     self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
 
     def test_domain_owner_consistency(self):
     def test_domain_owner_consistency(self):
@@ -349,7 +437,9 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
 
 
         with self.assertRaises(IntegrityError):
         with self.assertRaises(IntegrityError):
             with transaction.atomic():  # https://stackoverflow.com/a/23326971/6867099
             with transaction.atomic():  # https://stackoverflow.com/a/23326971/6867099
-                models.TokenDomainPolicy(token=self.token, domain=self.other_domains[0]).save()
+                models.TokenDomainPolicy(
+                    token=self.token, domain=self.other_domains[0]
+                ).save()
 
 
         self.token.user = self.other_domain.owner
         self.token.user = self.other_domain.owner
         with self.assertRaises(IntegrityError):
         with self.assertRaises(IntegrityError):
@@ -363,7 +453,10 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
 
 
         domain = domains.pop()
         domain = domains.pop()
         domain.delete()
         domain.delete()
-        self.assertEqual(list(map(lambda x: x.domain, self.token.tokendomainpolicy_set.all())), domains)
+        self.assertEqual(
+            list(map(lambda x: x.domain, self.token.tokendomainpolicy_set.all())),
+            domains,
+        )
 
 
     def test_token_deletion(self):
     def test_token_deletion(self):
         domains = [None] + self.my_domains[:2]
         domains = [None] + self.my_domains[:2]
@@ -375,7 +468,9 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
 
 
         self.token.delete()
         self.token.delete()
         for domain, policy in policies.items():
         for domain, policy in policies.items():
-            self.assertFalse(models.TokenDomainPolicy.objects.filter(pk=policy.pk).exists())
+            self.assertFalse(
+                models.TokenDomainPolicy.objects.filter(pk=policy.pk).exists()
+            )
             if domain:
             if domain:
                 self.assertTrue(models.Domain.objects.filter(pk=domain.pk).exists())
                 self.assertTrue(models.Domain.objects.filter(pk=domain.pk).exists())
 
 

+ 8 - 5
api/desecapi/tests/test_token_policies.py

@@ -4,7 +4,6 @@ from desecapi.tests.base import DomainOwnerTestCase
 
 
 
 
 class TokenPoliciesTestCase(DomainOwnerTestCase):
 class TokenPoliciesTestCase(DomainOwnerTestCase):
-
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
         self.client.credentials()  # remove default credential (corresponding to domain owner)
         self.client.credentials()  # remove default credential (corresponding to domain owner)
@@ -12,20 +11,24 @@ class TokenPoliciesTestCase(DomainOwnerTestCase):
         self.other_token = self.create_token(self.user)
         self.other_token = self.create_token(self.user)
 
 
     def test_policies(self):
     def test_policies(self):
-        url = DomainOwnerTestCase.reverse('v1:token-policies-root', token_id=self.token.id)
+        url = DomainOwnerTestCase.reverse(
+            "v1:token-policies-root", token_id=self.token.id
+        )
 
 
         kwargs = {}
         kwargs = {}
         response = self.client.get(url, **kwargs)
         response = self.client.get(url, **kwargs)
         self.assertStatus(response, status.HTTP_401_UNAUTHORIZED)
         self.assertStatus(response, status.HTTP_401_UNAUTHORIZED)
 
 
-        kwargs.update(HTTP_AUTHORIZATION=f'Token {self.token_manage.plain}')
+        kwargs.update(HTTP_AUTHORIZATION=f"Token {self.token_manage.plain}")
         response = self.client.get(url, **kwargs)
         response = self.client.get(url, **kwargs)
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
 
 
-        kwargs.update(HTTP_AUTHORIZATION=f'Token {self.token.plain}')
+        kwargs.update(HTTP_AUTHORIZATION=f"Token {self.token.plain}")
         response = self.client.get(url, **kwargs)
         response = self.client.get(url, **kwargs)
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
 
 
-        url = DomainOwnerTestCase.reverse('v1:token-policies-root', token_id=self.token_manage.id)
+        url = DomainOwnerTestCase.reverse(
+            "v1:token-policies-root", token_id=self.token_manage.id
+        )
         response = self.client.get(url, **kwargs)
         response = self.client.get(url, **kwargs)
         self.assertStatus(response, status.HTTP_403_FORBIDDEN)
         self.assertStatus(response, status.HTTP_403_FORBIDDEN)

+ 92 - 49
api/desecapi/tests/test_tokens.py

@@ -5,31 +5,37 @@ from desecapi.tests.base import DomainOwnerTestCase
 
 
 
 
 class TokenPermittedTestCase(DomainOwnerTestCase):
 class TokenPermittedTestCase(DomainOwnerTestCase):
-
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
         self.token.perm_manage_tokens = True
         self.token.perm_manage_tokens = True
         self.token.save()
         self.token.save()
-        self.token2 = self.create_token(self.owner, name='testtoken')
+        self.token2 = self.create_token(self.owner, name="testtoken")
         self.other_token = self.create_token(self.user)
         self.other_token = self.create_token(self.user)
 
 
     def test_token_last_used(self):
     def test_token_last_used(self):
         self.assertIsNone(Token.objects.get(pk=self.token.id).last_used)
         self.assertIsNone(Token.objects.get(pk=self.token.id).last_used)
-        self.client.get(self.reverse('v1:root'))
+        self.client.get(self.reverse("v1:root"))
         self.assertIsNotNone(Token.objects.get(pk=self.token.id).last_used)
         self.assertIsNotNone(Token.objects.get(pk=self.token.id).last_used)
 
 
     def test_list_tokens(self):
     def test_list_tokens(self):
-        response = self.client.get(self.reverse('v1:token-list'))
+        response = self.client.get(self.reverse("v1:token-list"))
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertEqual(len(response.data), 2)
         self.assertEqual(len(response.data), 2)
-        self.assertIn('id', response.data[0])
-        self.assertFalse(any(field in response.data[0] for field in ['token', 'key', 'value']))
-        self.assertFalse(any(token.encode() in response.content for token in [self.token.plain, self.token2.plain]))
+        self.assertIn("id", response.data[0])
+        self.assertFalse(
+            any(field in response.data[0] for field in ["token", "key", "value"])
+        )
+        self.assertFalse(
+            any(
+                token.encode() in response.content
+                for token in [self.token.plain, self.token2.plain]
+            )
+        )
         self.assertNotContains(response, self.token.plain)
         self.assertNotContains(response, self.token.plain)
 
 
     def test_delete_my_token(self):
     def test_delete_my_token(self):
-        token_id = Token.objects.get(user=self.owner, name='testtoken').id
-        url = self.reverse('v1:token-detail', pk=token_id)
+        token_id = Token.objects.get(user=self.owner, name="testtoken").id
+        url = self.reverse("v1:token-detail", pk=token_id)
 
 
         response = self.client.delete(url)
         response = self.client.delete(url)
         self.assertStatus(response, status.HTTP_204_NO_CONTENT)
         self.assertStatus(response, status.HTTP_204_NO_CONTENT)
@@ -39,37 +45,51 @@ class TokenPermittedTestCase(DomainOwnerTestCase):
         self.assertStatus(response, status.HTTP_404_NOT_FOUND)
         self.assertStatus(response, status.HTTP_404_NOT_FOUND)
 
 
     def test_retrieve_my_token(self):
     def test_retrieve_my_token(self):
-        token_id = Token.objects.get(user=self.owner, name='testtoken').id
-        url = self.reverse('v1:token-detail', pk=token_id)
+        token_id = Token.objects.get(user=self.owner, name="testtoken").id
+        url = self.reverse("v1:token-detail", pk=token_id)
 
 
         response = self.client.get(url)
         response = self.client.get(url)
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertEqual(
         self.assertEqual(
             set(response.data.keys()),
             set(response.data.keys()),
-            {'id', 'created', 'last_used', 'max_age', 'max_unused_period', 'name', 'perm_manage_tokens',
-             'allowed_subnets', 'is_valid'}
+            {
+                "id",
+                "created",
+                "last_used",
+                "max_age",
+                "max_unused_period",
+                "name",
+                "perm_manage_tokens",
+                "allowed_subnets",
+                "is_valid",
+            },
+        )
+        self.assertFalse(
+            any(
+                token.encode() in response.content
+                for token in [self.token.plain, self.token2.plain]
+            )
         )
         )
-        self.assertFalse(any(token.encode() in response.content for token in [self.token.plain, self.token2.plain]))
 
 
     def test_retrieve_other_token(self):
     def test_retrieve_other_token(self):
         token_id = Token.objects.get(user=self.user).id
         token_id = Token.objects.get(user=self.user).id
-        url = self.reverse('v1:token-detail', pk=token_id)
+        url = self.reverse("v1:token-detail", pk=token_id)
 
 
         response = self.client.get(url)
         response = self.client.get(url)
         self.assertStatus(response, status.HTTP_404_NOT_FOUND)
         self.assertStatus(response, status.HTTP_404_NOT_FOUND)
 
 
     def test_update_my_token(self):
     def test_update_my_token(self):
-        url = self.reverse('v1:token-detail', pk=self.token.id)
+        url = self.reverse("v1:token-detail", pk=self.token.id)
 
 
         for method in [self.client.patch, self.client.put]:
         for method in [self.client.patch, self.client.put]:
             datas = [
             datas = [
-                {'name': method.__name__},
-                {'allowed_subnets': ['127.0.0.0/8']},
-                {'allowed_subnets': ['127.0.0.0/8', '::/0']},
-                {'max_age': '365 00:10:33.123456'},
-                {'max_age': None},
-                {'max_unused_period': '365 00:10:33.123456'},
-                {'max_unused_period': None},
+                {"name": method.__name__},
+                {"allowed_subnets": ["127.0.0.0/8"]},
+                {"allowed_subnets": ["127.0.0.0/8", "::/0"]},
+                {"max_age": "365 00:10:33.123456"},
+                {"max_age": None},
+                {"max_unused_period": "365 00:10:33.123456"},
+                {"max_unused_period": None},
             ]
             ]
             for data in datas:
             for data in datas:
                 response = method(url, data=data)
                 response = method(url, data=data)
@@ -78,11 +98,11 @@ class TokenPermittedTestCase(DomainOwnerTestCase):
                     self.assertEqual(response.data[k], v)
                     self.assertEqual(response.data[k], v)
 
 
         # Revoke token management permission
         # Revoke token management permission
-        response = self.client.patch(url, data={'perm_manage_tokens': False})
+        response = self.client.patch(url, data={"perm_manage_tokens": False})
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
 
 
         # Verify that the change cannot be undone
         # Verify that the change cannot be undone
-        response = self.client.patch(url, data={'perm_manage_tokens': True})
+        response = self.client.patch(url, data={"perm_manage_tokens": True})
         self.assertStatus(response, status.HTTP_403_FORBIDDEN)
         self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
 
     def test_create_token(self):
     def test_create_token(self):
@@ -90,70 +110,93 @@ class TokenPermittedTestCase(DomainOwnerTestCase):
 
 
         datas = [
         datas = [
             {},
             {},
-            {'name': '', 'perm_manage_tokens': True},
-            {'name': 'foobar'},
-            {'allowed_subnets': ['1.2.3.32/28', 'bade::affe/128']},
+            {"name": "", "perm_manage_tokens": True},
+            {"name": "foobar"},
+            {"allowed_subnets": ["1.2.3.32/28", "bade::affe/128"]},
         ]
         ]
         for data in datas:
         for data in datas:
-            response = self.client.post(self.reverse('v1:token-list'), data=data)
+            response = self.client.post(self.reverse("v1:token-list"), data=data)
             self.assertStatus(response, status.HTTP_201_CREATED)
             self.assertStatus(response, status.HTTP_201_CREATED)
             self.assertEqual(
             self.assertEqual(
                 set(response.data.keys()),
                 set(response.data.keys()),
-                {'id', 'created', 'last_used', 'max_age', 'max_unused_period', 'name', 'perm_manage_tokens',
-                 'allowed_subnets', 'is_valid', 'token'}
+                {
+                    "id",
+                    "created",
+                    "last_used",
+                    "max_age",
+                    "max_unused_period",
+                    "name",
+                    "perm_manage_tokens",
+                    "allowed_subnets",
+                    "is_valid",
+                    "token",
+                },
             )
             )
-            self.assertEqual(response.data['name'], data.get('name', ''))
-            self.assertEqual(response.data['allowed_subnets'], data.get('allowed_subnets', ['0.0.0.0/0', '::/0']))
-            self.assertEqual(response.data['perm_manage_tokens'], data.get('perm_manage_tokens', False))
-            self.assertIsNone(response.data['last_used'])
+            self.assertEqual(response.data["name"], data.get("name", ""))
+            self.assertEqual(
+                response.data["allowed_subnets"],
+                data.get("allowed_subnets", ["0.0.0.0/0", "::/0"]),
+            )
+            self.assertEqual(
+                response.data["perm_manage_tokens"],
+                data.get("perm_manage_tokens", False),
+            )
+            self.assertIsNone(response.data["last_used"])
 
 
-        self.assertEqual(len(Token.objects.filter(user=self.owner).all()), n + len(datas))
+        self.assertEqual(
+            len(Token.objects.filter(user=self.owner).all()), n + len(datas)
+        )
 
 
 
 
 class TokenForbiddenTestCase(DomainOwnerTestCase):
 class TokenForbiddenTestCase(DomainOwnerTestCase):
-
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
-        self.token2 = self.create_token(self.owner, name='testtoken')
+        self.token2 = self.create_token(self.owner, name="testtoken")
         self.other_token = self.create_token(self.user)
         self.other_token = self.create_token(self.user)
 
 
     def test_token_last_used(self):
     def test_token_last_used(self):
         self.assertIsNone(Token.objects.get(pk=self.token.id).last_used)
         self.assertIsNone(Token.objects.get(pk=self.token.id).last_used)
-        self.client.get(self.reverse('v1:root'))
+        self.client.get(self.reverse("v1:root"))
         self.assertIsNotNone(Token.objects.get(pk=self.token.id).last_used)
         self.assertIsNotNone(Token.objects.get(pk=self.token.id).last_used)
 
 
     def test_list_tokens(self):
     def test_list_tokens(self):
-        response = self.client.get(self.reverse('v1:token-list'))
+        response = self.client.get(self.reverse("v1:token-list"))
         self.assertStatus(response, status.HTTP_403_FORBIDDEN)
         self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
 
     def test_delete_my_token(self):
     def test_delete_my_token(self):
-        for token_id in [Token.objects.get(user=self.owner, name='testtoken').id, self.token.id]:
-            url = self.reverse('v1:token-detail', pk=token_id)
+        for token_id in [
+            Token.objects.get(user=self.owner, name="testtoken").id,
+            self.token.id,
+        ]:
+            url = self.reverse("v1:token-detail", pk=token_id)
             response = self.client.delete(url)
             response = self.client.delete(url)
             self.assertStatus(response, status.HTTP_403_FORBIDDEN)
             self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
 
     def test_retrieve_my_token(self):
     def test_retrieve_my_token(self):
-        for token_id in [Token.objects.get(user=self.owner, name='testtoken').id, self.token.id]:
-            url = self.reverse('v1:token-detail', pk=token_id)
+        for token_id in [
+            Token.objects.get(user=self.owner, name="testtoken").id,
+            self.token.id,
+        ]:
+            url = self.reverse("v1:token-detail", pk=token_id)
             response = self.client.get(url)
             response = self.client.get(url)
             self.assertStatus(response, status.HTTP_403_FORBIDDEN)
             self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
 
     def test_retrieve_other_token(self):
     def test_retrieve_other_token(self):
         token_id = Token.objects.get(user=self.user).id
         token_id = Token.objects.get(user=self.user).id
-        url = self.reverse('v1:token-detail', pk=token_id)
+        url = self.reverse("v1:token-detail", pk=token_id)
         response = self.client.get(url)
         response = self.client.get(url)
         self.assertStatus(response, status.HTTP_403_FORBIDDEN)
         self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
 
     def test_update_my_token(self):
     def test_update_my_token(self):
-        url = self.reverse('v1:token-detail', pk=self.token.id)
+        url = self.reverse("v1:token-detail", pk=self.token.id)
         for method in [self.client.patch, self.client.put]:
         for method in [self.client.patch, self.client.put]:
-            datas = [{'name': method.__name__}, {'allowed_subnets': ['127.0.0.0/8']}]
+            datas = [{"name": method.__name__}, {"allowed_subnets": ["127.0.0.0/8"]}]
             for data in datas:
             for data in datas:
                 response = method(url, data=data)
                 response = method(url, data=data)
                 self.assertStatus(response, status.HTTP_403_FORBIDDEN)
                 self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
 
     def test_create_token(self):
     def test_create_token(self):
-        datas = [{}, {'name': ''}, {'name': 'foobar'}]
+        datas = [{}, {"name": ""}, {"name": "foobar"}]
         for data in datas:
         for data in datas:
-            response = self.client.post(self.reverse('v1:token-list'), data=data)
+            response = self.client.post(self.reverse("v1:token-list"), data=data)
             self.assertStatus(response, status.HTTP_403_FORBIDDEN)
             self.assertStatus(response, status.HTTP_403_FORBIDDEN)

File diff suppressed because it is too large
+ 303 - 179
api/desecapi/tests/test_user_management.py


+ 16 - 6
api/desecapi/throttling.py

@@ -10,6 +10,7 @@ class ScopedRatesThrottle(throttling.ScopedRateThrottle):
     """
     """
     Like DRF's ScopedRateThrottle, but supports several rates per scope, e.g. for burst vs. sustained limit.
     Like DRF's ScopedRateThrottle, but supports several rates per scope, e.g. for burst vs. sustained limit.
     """
     """
+
     def parse_rate(self, rates):
     def parse_rate(self, rates):
         return [super(ScopedRatesThrottle, self).parse_rate(rate) for rate in rates]
         return [super(ScopedRatesThrottle, self).parse_rate(rate) for rate in rates]
 
 
@@ -27,9 +28,9 @@ class ScopedRatesThrottle(throttling.ScopedRateThrottle):
             return True
             return True
 
 
         # Amend scope with optional bucket
         # Amend scope with optional bucket
-        bucket = getattr(view, self.scope_attr + '_bucket', None)
+        bucket = getattr(view, self.scope_attr + "_bucket", None)
         if bucket is not None:
         if bucket is not None:
-            self.scope += ':' + sha1(bucket.encode()).hexdigest()
+            self.scope += ":" + sha1(bucket.encode()).hexdigest()
 
 
         self.now = self.timer()
         self.now = self.timer()
         self.num_requests, self.duration = zip(*self.parse_rate(self.rate))
         self.num_requests, self.duration = zip(*self.parse_rate(self.rate))
@@ -37,7 +38,9 @@ class ScopedRatesThrottle(throttling.ScopedRateThrottle):
         self.history = {key: [] for key in self.key}
         self.history = {key: [] for key in self.key}
         self.history.update(self.cache.get_many(self.key))
         self.history.update(self.cache.get_many(self.key))
 
 
-        for num_requests, duration, key in zip(self.num_requests, self.duration, self.key):
+        for num_requests, duration, key in zip(
+            self.num_requests, self.duration, self.key
+        ):
             history = self.history[key]
             history = self.history[key]
             # Drop any requests from the history which have now passed the
             # Drop any requests from the history which have now passed the
             # throttle duration
             # throttle duration
@@ -45,9 +48,16 @@ class ScopedRatesThrottle(throttling.ScopedRateThrottle):
                 history.pop()
                 history.pop()
             if len(history) >= num_requests:
             if len(history) >= num_requests:
                 # Prepare variables used by the Throttle's wait() method that gets called by APIView.check_throttles()
                 # Prepare variables used by the Throttle's wait() method that gets called by APIView.check_throttles()
-                self.num_requests, self.duration, self.key, self.history = num_requests, duration, key, history
+                self.num_requests, self.duration, self.key, self.history = (
+                    num_requests,
+                    duration,
+                    key,
+                    history,
+                )
                 response = self.throttle_failure()
                 response = self.throttle_failure()
-                metrics.get('desecapi_throttle_failure').labels(request.method, scope, request.user.pk, bucket).inc()
+                metrics.get("desecapi_throttle_failure").labels(
+                    request.method, scope, request.user.pk, bucket
+                ).inc()
                 return response
                 return response
             self.history[key] = history
             self.history[key] = history
         return self.throttle_success()
         return self.throttle_success()
@@ -65,4 +75,4 @@ class ScopedRatesThrottle(throttling.ScopedRateThrottle):
 
 
     def get_cache_key(self, request, view):
     def get_cache_key(self, request, view):
         key = super().get_cache_key(request, view)
         key = super().get_cache_key(request, view)
-        return [f'{key}_{duration}' for duration in self.duration]
+        return [f"{key}_{duration}" for duration in self.duration]

+ 96 - 44
api/desecapi/urls/version_1.py

@@ -4,69 +4,121 @@ from rest_framework.routers import SimpleRouter
 from desecapi import views
 from desecapi import views
 
 
 tokens_router = SimpleRouter()
 tokens_router = SimpleRouter()
-tokens_router.register(r'', views.TokenViewSet, basename='token')
+tokens_router.register(r"", views.TokenViewSet, basename="token")
 
 
 tokendomainpolicies_router = SimpleRouter()
 tokendomainpolicies_router = SimpleRouter()
-tokendomainpolicies_router.register(r'', views.TokenDomainPolicyViewSet, basename='token_domain_policies')
+tokendomainpolicies_router.register(
+    r"", views.TokenDomainPolicyViewSet, basename="token_domain_policies"
+)
 
 
 auth_urls = [
 auth_urls = [
     # User management
     # User management
-    path('', views.AccountCreateView.as_view(), name='register'),
-    path('account/', views.AccountView.as_view(), name='account'),
-    path('account/delete/', views.AccountDeleteView.as_view(), name='account-delete'),
-    path('account/change-email/', views.AccountChangeEmailView.as_view(), name='account-change-email'),
-    path('account/reset-password/', views.AccountResetPasswordView.as_view(), name='account-reset-password'),
-    path('login/', views.AccountLoginView.as_view(), name='login'),
-    path('logout/', views.AccountLogoutView.as_view(), name='logout'),
-
+    path("", views.AccountCreateView.as_view(), name="register"),
+    path("account/", views.AccountView.as_view(), name="account"),
+    path("account/delete/", views.AccountDeleteView.as_view(), name="account-delete"),
+    path(
+        "account/change-email/",
+        views.AccountChangeEmailView.as_view(),
+        name="account-change-email",
+    ),
+    path(
+        "account/reset-password/",
+        views.AccountResetPasswordView.as_view(),
+        name="account-reset-password",
+    ),
+    path("login/", views.AccountLoginView.as_view(), name="login"),
+    path("logout/", views.AccountLogoutView.as_view(), name="logout"),
     # Token management
     # Token management
-    path('tokens/', include(tokens_router.urls)),
-    path('tokens/<uuid:token_id>/policies/', views.TokenPoliciesRoot.as_view(), name='token-policies-root'),
-    path('tokens/<uuid:token_id>/policies/domain/', include(tokendomainpolicies_router.urls)),
+    path("tokens/", include(tokens_router.urls)),
+    path(
+        "tokens/<uuid:token_id>/policies/",
+        views.TokenPoliciesRoot.as_view(),
+        name="token-policies-root",
+    ),
+    path(
+        "tokens/<uuid:token_id>/policies/domain/",
+        include(tokendomainpolicies_router.urls),
+    ),
 ]
 ]
 
 
 domains_router = SimpleRouter()
 domains_router = SimpleRouter()
-domains_router.register(r'', views.DomainViewSet, basename='domain')
+domains_router.register(r"", views.DomainViewSet, basename="domain")
 
 
 api_urls = [
 api_urls = [
     # API home
     # API home
-    path('', views.Root.as_view(), name='root'),
-
+    path("", views.Root.as_view(), name="root"),
     # Domain and RRSet management
     # Domain and RRSet management
-    path('domains/', include(domains_router.urls)),
-    path('domains/<name>/rrsets/', views.RRsetList.as_view(), name='rrsets'),
-    path('domains/<name>/rrsets/.../<type>/', views.RRsetDetail.as_view(), kwargs={'subname': ''}),
-    re_path(r'^domains/(?P<name>[^/]+)/rrsets/(?P<subname>[^/]*)\.\.\./(?P<type>[^/]+)/$',
-            views.RRsetDetail.as_view(), name='rrset'),
-    path('domains/<name>/rrsets/@/<type>/', views.RRsetDetail.as_view(), kwargs={'subname': ''}),
-    re_path(r'^domains/(?P<name>[^/]+)/rrsets/(?P<subname>[^/]*)@/(?P<type>[^/]+)/$',
-            views.RRsetDetail.as_view(), name='rrset@'),
-    path('domains/<name>/rrsets/<subname>/<type>/', views.RRsetDetail.as_view()),
-
+    path("domains/", include(domains_router.urls)),
+    path("domains/<name>/rrsets/", views.RRsetList.as_view(), name="rrsets"),
+    path(
+        "domains/<name>/rrsets/.../<type>/",
+        views.RRsetDetail.as_view(),
+        kwargs={"subname": ""},
+    ),
+    re_path(
+        r"^domains/(?P<name>[^/]+)/rrsets/(?P<subname>[^/]*)\.\.\./(?P<type>[^/]+)/$",
+        views.RRsetDetail.as_view(),
+        name="rrset",
+    ),
+    path(
+        "domains/<name>/rrsets/@/<type>/",
+        views.RRsetDetail.as_view(),
+        kwargs={"subname": ""},
+    ),
+    re_path(
+        r"^domains/(?P<name>[^/]+)/rrsets/(?P<subname>[^/]*)@/(?P<type>[^/]+)/$",
+        views.RRsetDetail.as_view(),
+        name="rrset@",
+    ),
+    path("domains/<name>/rrsets/<subname>/<type>/", views.RRsetDetail.as_view()),
     # DynDNS update
     # DynDNS update
-    path('dyndns/update', views.DynDNS12UpdateView.as_view(), name='dyndns12update'),
-
+    path("dyndns/update", views.DynDNS12UpdateView.as_view(), name="dyndns12update"),
     # Serials
     # Serials
-    path('serials/', views.SerialListView.as_view(), name='serial'),
-
+    path("serials/", views.SerialListView.as_view(), name="serial"),
     # Donation
     # Donation
-    path('donation/', views.DonationList.as_view(), name='donation'),
-
+    path("donation/", views.DonationList.as_view(), name="donation"),
     # Authenticated Actions
     # Authenticated Actions
-    path('v/activate-account/<code>/', views.AuthenticatedActivateUserActionView.as_view(), name='confirm-activate-account'),
-    path('v/change-email/<code>/', views.AuthenticatedChangeEmailUserActionView.as_view(), name='confirm-change-email'),
-    path('v/change-outreach-preference/<code>/', views.AuthenticatedChangeOutreachPreferenceUserActionView.as_view(), name='confirm-change-outreach-preference'),
-    path('v/confirm-account/<code>/', views.AuthenticatedConfirmAccountUserActionView.as_view(), name='confirm-confirm-account'),
-    path('v/reset-password/<code>/', views.AuthenticatedResetPasswordUserActionView.as_view(), name='confirm-reset-password'),
-    path('v/delete-account/<code>/', views.AuthenticatedDeleteUserActionView.as_view(), name='confirm-delete-account'),
-    path('v/renew-domain/<code>/', views.AuthenticatedRenewDomainBasicUserActionView.as_view(), name='confirm-renew-domain'),
-
+    path(
+        "v/activate-account/<code>/",
+        views.AuthenticatedActivateUserActionView.as_view(),
+        name="confirm-activate-account",
+    ),
+    path(
+        "v/change-email/<code>/",
+        views.AuthenticatedChangeEmailUserActionView.as_view(),
+        name="confirm-change-email",
+    ),
+    path(
+        "v/change-outreach-preference/<code>/",
+        views.AuthenticatedChangeOutreachPreferenceUserActionView.as_view(),
+        name="confirm-change-outreach-preference",
+    ),
+    path(
+        "v/confirm-account/<code>/",
+        views.AuthenticatedConfirmAccountUserActionView.as_view(),
+        name="confirm-confirm-account",
+    ),
+    path(
+        "v/reset-password/<code>/",
+        views.AuthenticatedResetPasswordUserActionView.as_view(),
+        name="confirm-reset-password",
+    ),
+    path(
+        "v/delete-account/<code>/",
+        views.AuthenticatedDeleteUserActionView.as_view(),
+        name="confirm-delete-account",
+    ),
+    path(
+        "v/renew-domain/<code>/",
+        views.AuthenticatedRenewDomainBasicUserActionView.as_view(),
+        name="confirm-renew-domain",
+    ),
     # CAPTCHA
     # CAPTCHA
-    path('captcha/', views.CaptchaView.as_view(), name='captcha'),
+    path("captcha/", views.CaptchaView.as_view(), name="captcha"),
 ]
 ]
 
 
-app_name = 'desecapi'
+app_name = "desecapi"
 urlpatterns = [
 urlpatterns = [
-    path('auth/', include(auth_urls)),
-    path('', include(api_urls)),
+    path("auth/", include(auth_urls)),
+    path("", include(api_urls)),
 ]
 ]

+ 1 - 1
api/desecapi/urls/version_2.py

@@ -1,4 +1,4 @@
 from desecapi.urls.version_1 import urlpatterns as version_1
 from desecapi.urls.version_1 import urlpatterns as version_1
 
 
-app_name = 'desecapi'
+app_name = "desecapi"
 urlpatterns = version_1
 urlpatterns = version_1

+ 12 - 10
api/desecapi/validators.py

@@ -18,7 +18,8 @@ class ExclusionConstraintValidator(UniqueTogetherValidator):
     Should be applied to the serializer class, not to an individual field.
     Should be applied to the serializer class, not to an individual field.
     No-op if parent serializer is a list serializer (many=True). We expect the list serializer to assure exclusivity.
     No-op if parent serializer is a list serializer (many=True). We expect the list serializer to assure exclusivity.
     """
     """
-    message = 'This field violates an exclusion constraint.'
+
+    message = "This field violates an exclusion constraint."
 
 
     def __init__(self, queryset, fields, exclusion_condition, message=None):
     def __init__(self, queryset, fields, exclusion_condition, message=None):
         super().__init__(queryset, fields, message)
         super().__init__(queryset, fields, message)
@@ -39,7 +40,7 @@ class ExclusionConstraintValidator(UniqueTogetherValidator):
 
 
     def __call__(self, attrs, serializer, *args, **kwargs):
     def __call__(self, attrs, serializer, *args, **kwargs):
         # Ignore validation if the many flag is set
         # Ignore validation if the many flag is set
-        if getattr(serializer.root, 'many', False):
+        if getattr(serializer.root, "many", False):
             return
             return
 
 
         self.enforce_required_fields(attrs, serializer)
         self.enforce_required_fields(attrs, serializer)
@@ -52,15 +53,15 @@ class ExclusionConstraintValidator(UniqueTogetherValidator):
             value for field, value in attrs.items() if field in self.fields
             value for field, value in attrs.items() if field in self.fields
         ]
         ]
         if None not in checked_values and qs_exists(queryset):
         if None not in checked_values and qs_exists(queryset):
-            types = queryset.values_list('type', flat=True)
-            types = ', '.join(types)
+            types = queryset.values_list("type", flat=True)
+            types = ", ".join(types)
             message = self.message.format(types=types)
             message = self.message.format(types=types)
-            raise ValidationError(message, code='exclusive')
+            raise ValidationError(message, code="exclusive")
 
 
 
 
 class Validator:
 class Validator:
 
 
-    message = 'This field did not pass validation.'
+    message = "This field did not pass validation."
 
 
     def __init__(self, message=None):
     def __init__(self, message=None):
         self.field_name = None
         self.field_name = None
@@ -71,15 +72,16 @@ class Validator:
         raise NotImplementedError
         raise NotImplementedError
 
 
     def __repr__(self):
     def __repr__(self):
-        return '<%s>' % self.__class__.__name__
+        return "<%s>" % self.__class__.__name__
+
 
 
 class ReadOnlyOnUpdateValidator(Validator):
 class ReadOnlyOnUpdateValidator(Validator):
 
 
-    message = 'Can only be written on create.'
+    message = "Can only be written on create."
     requires_context = True
     requires_context = True
 
 
     def __call__(self, value, serializer_field):
     def __call__(self, value, serializer_field):
         field_name = serializer_field.source_attrs[-1]
         field_name = serializer_field.source_attrs[-1]
-        instance = getattr(serializer_field.parent, 'instance', None)
+        instance = getattr(serializer_field.parent, "instance", None)
         if isinstance(instance, Model) and value != getattr(instance, field_name):
         if isinstance(instance, Model) and value != getattr(instance, field_name):
-            raise serializers.ValidationError(self.message, code='read-only-on-update')
+            raise serializers.ValidationError(self.message, code="read-only-on-update")

+ 92 - 45
api/desecapi/views/authenticated_actions.py

@@ -26,9 +26,10 @@ class AuthenticatedActionView(generics.GenericAPIView):
     Accept: text/html	forward to `self.html_url` if any   perform action      405 Method Not Allowed
     Accept: text/html	forward to `self.html_url` if any   perform action      405 Method Not Allowed
     else                HTTP 406 Not Acceptable             perform action      405 Method Not Allowed
     else                HTTP 406 Not Acceptable             perform action      405 Method Not Allowed
     """
     """
+
     authenticated_action = None
     authenticated_action = None
     html_url = None  # Redirect GET requests to this webapp GUI URL
     html_url = None  # Redirect GET requests to this webapp GUI URL
-    http_method_names = ['get', 'post']  # GET is for redirect only
+    http_method_names = ["get", "post"]  # GET is for redirect only
     renderer_classes = [JSONRenderer, StaticHTMLRenderer]
     renderer_classes = [JSONRenderer, StaticHTMLRenderer]
     _authenticated_action = None
     _authenticated_action = None
 
 
@@ -38,10 +39,14 @@ class AuthenticatedActionView(generics.GenericAPIView):
             serializer = self.get_serializer(data=self.request.data)
             serializer = self.get_serializer(data=self.request.data)
             serializer.is_valid(raise_exception=True)
             serializer.is_valid(raise_exception=True)
             try:
             try:
-                self._authenticated_action = serializer.Meta.model(**serializer.validated_data)
+                self._authenticated_action = serializer.Meta.model(
+                    **serializer.validated_data
+                )
             except ValueError:  # this happens when state cannot be verified
             except ValueError:  # this happens when state cannot be verified
-                ex = ValidationError('This action cannot be carried out because another operation has been performed, '
-                                     'invalidating this one. (Are you trying to perform this action twice?)')
+                ex = ValidationError(
+                    "This action cannot be carried out because another operation has been performed, "
+                    "invalidating this one. (Are you trying to perform this action twice?)"
+                )
                 ex.status_code = status.HTTP_409_CONFLICT
                 ex.status_code = status.HTTP_409_CONFLICT
                 raise ex
                 raise ex
         return self._authenticated_action
         return self._authenticated_action
@@ -49,26 +54,38 @@ class AuthenticatedActionView(generics.GenericAPIView):
     @property
     @property
     def authentication_classes(self):
     def authentication_classes(self):
         # This prevents both auth action code evaluation and user-specific throttling when we only want a redirect
         # This prevents both auth action code evaluation and user-specific throttling when we only want a redirect
-        return () if self.request.method in SAFE_METHODS else (auth.AuthenticatedBasicUserActionAuthentication,)
+        return (
+            ()
+            if self.request.method in SAFE_METHODS
+            else (auth.AuthenticatedBasicUserActionAuthentication,)
+        )
 
 
     @property
     @property
     def permission_classes(self):
     def permission_classes(self):
-        return () if self.request.method in SAFE_METHODS else (permissions.IsActiveUser,)
+        return (
+            () if self.request.method in SAFE_METHODS else (permissions.IsActiveUser,)
+        )
 
 
     @property
     @property
     def throttle_scope(self):
     def throttle_scope(self):
-        return 'account_management_passive' if self.request.method in SAFE_METHODS else 'account_management_active'
+        return (
+            "account_management_passive"
+            if self.request.method in SAFE_METHODS
+            else "account_management_active"
+        )
 
 
     def get_serializer_context(self):
     def get_serializer_context(self):
         return {
         return {
             **super().get_serializer_context(),
             **super().get_serializer_context(),
-            'code': self.kwargs['code'],
-            'validity_period': self.get_serializer_class().validity_period,
+            "code": self.kwargs["code"],
+            "validity_period": self.get_serializer_class().validity_period,
         }
         }
 
 
     def get(self, request, *args, **kwargs):
     def get(self, request, *args, **kwargs):
         # Redirect browsers to frontend if available
         # Redirect browsers to frontend if available
-        is_redirect = (request.accepted_renderer.format == 'html') and self.html_url is not None
+        is_redirect = (
+            request.accepted_renderer.format == "html"
+        ) and self.html_url is not None
         if is_redirect:
         if is_redirect:
             # Careful: This can generally lead to an open redirect if values contain slashes!
             # Careful: This can generally lead to an open redirect if values contain slashes!
             # However, it cannot happen for Django view kwargs.
             # However, it cannot happen for Django view kwargs.
@@ -82,16 +99,22 @@ class AuthenticatedActionView(generics.GenericAPIView):
 
 
 
 
 class AuthenticatedChangeOutreachPreferenceUserActionView(AuthenticatedActionView):
 class AuthenticatedChangeOutreachPreferenceUserActionView(AuthenticatedActionView):
-    html_url = '/confirm/change-outreach-preference/{code}/'
-    serializer_class = serializers.AuthenticatedChangeOutreachPreferenceUserActionSerializer
+    html_url = "/confirm/change-outreach-preference/{code}/"
+    serializer_class = (
+        serializers.AuthenticatedChangeOutreachPreferenceUserActionSerializer
+    )
 
 
     def post(self, request, *args, **kwargs):
     def post(self, request, *args, **kwargs):
         super().post(request, *args, **kwargs)
         super().post(request, *args, **kwargs)
-        return Response({'detail': 'Thank you! We have recorded that you would not like to receive outreach messages.'})
+        return Response(
+            {
+                "detail": "Thank you! We have recorded that you would not like to receive outreach messages."
+            }
+        )
 
 
 
 
 class AuthenticatedActivateUserActionView(AuthenticatedActionView):
 class AuthenticatedActivateUserActionView(AuthenticatedActionView):
-    html_url = '/confirm/activate-account/{code}/'
+    html_url = "/confirm/activate-account/{code}/"
     permission_classes = ()  # don't require that user is activated already
     permission_classes = ()  # don't require that user is activated already
     serializer_class = serializers.AuthenticatedActivateUserActionSerializer
     serializer_class = serializers.AuthenticatedActivateUserActionSerializer
 
 
@@ -105,17 +128,17 @@ class AuthenticatedActivateUserActionView(AuthenticatedActionView):
 
 
     def _create_domain(self):
     def _create_domain(self):
         serializer = serializers.DomainSerializer(
         serializer = serializers.DomainSerializer(
-            data={'name': self.authenticated_action.domain},
-            context=self.get_serializer_context()
+            data={"name": self.authenticated_action.domain},
+            context=self.get_serializer_context(),
         )
         )
         try:
         try:
             serializer.is_valid(raise_exception=True)
             serializer.is_valid(raise_exception=True)
         except ValidationError as e:  # e.g. domain name unavailable
         except ValidationError as e:  # e.g. domain name unavailable
             self.request.user.delete()
             self.request.user.delete()
-            reasons = ', '.join([detail.code for detail in e.detail.get('name', [])])
+            reasons = ", ".join([detail.code for detail in e.detail.get("name", [])])
             raise ValidationError(
             raise ValidationError(
-                f'The requested domain {self.authenticated_action.domain} could not be registered (reason: {reasons}). '
-                f'Please start over and sign up again.'
+                f"The requested domain {self.authenticated_action.domain} could not be registered (reason: {reasons}). "
+                f"Please start over and sign up again."
             )
             )
         # TODO the following line is subject to race condition and can fail, as for the domain name, we have that
         # TODO the following line is subject to race condition and can fail, as for the domain name, we have that
         #  time-of-check != time-of-action
         #  time-of-check != time-of-action
@@ -123,72 +146,96 @@ class AuthenticatedActivateUserActionView(AuthenticatedActionView):
 
 
     def _finalize_without_domain(self):
     def _finalize_without_domain(self):
         if not is_password_usable(self.request.user.password):
         if not is_password_usable(self.request.user.password):
-            serializers.AuthenticatedResetPasswordUserActionSerializer.build_and_save(user=self.request.user)
-            return Response({'detail': 'Success! We sent you instructions on how to set your password.'})
-        return Response({'detail': 'Success! Your account has been activated, and you can now log in.'})
+            serializers.AuthenticatedResetPasswordUserActionSerializer.build_and_save(
+                user=self.request.user
+            )
+            return Response(
+                {
+                    "detail": "Success! We sent you instructions on how to set your password."
+                }
+            )
+        return Response(
+            {
+                "detail": "Success! Your account has been activated, and you can now log in."
+            }
+        )
 
 
     def _finalize_with_domain(self, domain):
     def _finalize_with_domain(self, domain):
         if domain.is_locally_registrable:
         if domain.is_locally_registrable:
             # TODO the following line raises Domain.DoesNotExist under unknown conditions
             # TODO the following line raises Domain.DoesNotExist under unknown conditions
             PDNSChangeTracker.track(lambda: DomainViewSet.auto_delegate(domain))
             PDNSChangeTracker.track(lambda: DomainViewSet.auto_delegate(domain))
-            token = Token.objects.create(user=domain.owner, name='dyndns')
-            return Response({
-                'detail': 'Success! Here is the password ("token") to configure your router (or any other dynDNS '
-                          'client). This password is different from your account password for security reasons.',
-                'domain': serializers.DomainSerializer(domain).data,
-                **serializers.TokenSerializer(token, include_plain=True).data,
-            })
+            token = Token.objects.create(user=domain.owner, name="dyndns")
+            return Response(
+                {
+                    "detail": 'Success! Here is the password ("token") to configure your router (or any other dynDNS '
+                    "client). This password is different from your account password for security reasons.",
+                    "domain": serializers.DomainSerializer(domain).data,
+                    **serializers.TokenSerializer(token, include_plain=True).data,
+                }
+            )
         else:
         else:
-            return Response({
-                'detail': 'Success! Please check the docs for the next steps, https://desec.readthedocs.io/.',
-                'domain': serializers.DomainSerializer(domain, include_keys=True).data,
-            })
+            return Response(
+                {
+                    "detail": "Success! Please check the docs for the next steps, https://desec.readthedocs.io/.",
+                    "domain": serializers.DomainSerializer(
+                        domain, include_keys=True
+                    ).data,
+                }
+            )
 
 
 
 
 class AuthenticatedChangeEmailUserActionView(AuthenticatedActionView):
 class AuthenticatedChangeEmailUserActionView(AuthenticatedActionView):
-    html_url = '/confirm/change-email/{code}/'
+    html_url = "/confirm/change-email/{code}/"
     serializer_class = serializers.AuthenticatedChangeEmailUserActionSerializer
     serializer_class = serializers.AuthenticatedChangeEmailUserActionSerializer
 
 
     def post(self, request, *args, **kwargs):
     def post(self, request, *args, **kwargs):
         super().post(request, *args, **kwargs)
         super().post(request, *args, **kwargs)
-        return Response({
-            'detail': f'Success! Your email address has been changed to {self.authenticated_action.user.email}.'
-        })
+        return Response(
+            {
+                "detail": f"Success! Your email address has been changed to {self.authenticated_action.user.email}."
+            }
+        )
 
 
 
 
 class AuthenticatedConfirmAccountUserActionView(AuthenticatedActionView):
 class AuthenticatedConfirmAccountUserActionView(AuthenticatedActionView):
-    html_url = '/confirm/confirm-account/{code}'
+    html_url = "/confirm/confirm-account/{code}"
     serializer_class = serializers.AuthenticatedConfirmAccountUserActionSerializer
     serializer_class = serializers.AuthenticatedConfirmAccountUserActionSerializer
 
 
     def post(self, request, *args, **kwargs):
     def post(self, request, *args, **kwargs):
         super().post(request, *args, **kwargs)
         super().post(request, *args, **kwargs)
-        return Response({'detail': 'Success! Your account status has been confirmed.'})
+        return Response({"detail": "Success! Your account status has been confirmed."})
 
 
 
 
 class AuthenticatedResetPasswordUserActionView(AuthenticatedActionView):
 class AuthenticatedResetPasswordUserActionView(AuthenticatedActionView):
-    html_url = '/confirm/reset-password/{code}/'
+    html_url = "/confirm/reset-password/{code}/"
     serializer_class = serializers.AuthenticatedResetPasswordUserActionSerializer
     serializer_class = serializers.AuthenticatedResetPasswordUserActionSerializer
 
 
     def post(self, request, *args, **kwargs):
     def post(self, request, *args, **kwargs):
         super().post(request, *args, **kwargs)
         super().post(request, *args, **kwargs)
-        return Response({'detail': 'Success! Your password has been changed.'})
+        return Response({"detail": "Success! Your password has been changed."})
 
 
 
 
 class AuthenticatedDeleteUserActionView(AuthenticatedActionView):
 class AuthenticatedDeleteUserActionView(AuthenticatedActionView):
-    html_url = '/confirm/delete-account/{code}/'
+    html_url = "/confirm/delete-account/{code}/"
     serializer_class = serializers.AuthenticatedDeleteUserActionSerializer
     serializer_class = serializers.AuthenticatedDeleteUserActionSerializer
 
 
     def post(self, request, *args, **kwargs):
     def post(self, request, *args, **kwargs):
         if self.request.user.domains.exists():
         if self.request.user.domains.exists():
             return AccountDeleteView.response_still_has_domains
             return AccountDeleteView.response_still_has_domains
         super().post(request, *args, **kwargs)
         super().post(request, *args, **kwargs)
-        return Response({'detail': 'All your data has been deleted. Bye bye, see you soon! <3'})
+        return Response(
+            {"detail": "All your data has been deleted. Bye bye, see you soon! <3"}
+        )
 
 
 
 
 class AuthenticatedRenewDomainBasicUserActionView(AuthenticatedActionView):
 class AuthenticatedRenewDomainBasicUserActionView(AuthenticatedActionView):
-    html_url = '/confirm/renew-domain/{code}/'
+    html_url = "/confirm/renew-domain/{code}/"
     serializer_class = serializers.AuthenticatedRenewDomainBasicUserActionSerializer
     serializer_class = serializers.AuthenticatedRenewDomainBasicUserActionSerializer
 
 
     def post(self, request, *args, **kwargs):
     def post(self, request, *args, **kwargs):
         super().post(request, *args, **kwargs)
         super().post(request, *args, **kwargs)
-        return Response({'detail': f'We recorded that your domain {self.authenticated_action.domain} is still in use.'})
+        return Response(
+            {
+                "detail": f"We recorded that your domain {self.authenticated_action.domain} is still in use."
+            }
+        )

+ 13 - 11
api/desecapi/views/base.py

@@ -19,20 +19,22 @@ class Root(APIView):
     def get(self, request, *args, **kwargs):
     def get(self, request, *args, **kwargs):
         if self.request.user.is_authenticated:
         if self.request.user.is_authenticated:
             routes = {
             routes = {
-                'account': {
-                    'show': reverse('account', request=request),
-                    'delete': reverse('account-delete', request=request),
-                    'change-email': reverse('account-change-email', request=request),
-                    'reset-password': reverse('account-reset-password', request=request),
+                "account": {
+                    "show": reverse("account", request=request),
+                    "delete": reverse("account-delete", request=request),
+                    "change-email": reverse("account-change-email", request=request),
+                    "reset-password": reverse(
+                        "account-reset-password", request=request
+                    ),
                 },
                 },
-                'logout': reverse('logout', request=request),
-                'tokens': reverse('token-list', request=request),
-                'domains': reverse('domain-list', request=request),
+                "logout": reverse("logout", request=request),
+                "tokens": reverse("token-list", request=request),
+                "domains": reverse("domain-list", request=request),
             }
             }
         else:
         else:
             routes = {
             routes = {
-                'register': reverse('register', request=request),
-                'login': reverse('login', request=request),
-                'reset-password': reverse('account-reset-password', request=request),
+                "register": reverse("register", request=request),
+                "login": reverse("login", request=request),
+                "reset-password": reverse("account-reset-password", request=request),
             }
             }
         return Response(routes)
         return Response(routes)

+ 1 - 1
api/desecapi/views/captcha.py

@@ -5,4 +5,4 @@ from desecapi.serializers import CaptchaSerializer
 
 
 class CaptchaView(generics.CreateAPIView):
 class CaptchaView(generics.CreateAPIView):
     serializer_class = CaptchaSerializer
     serializer_class = CaptchaSerializer
-    throttle_scope = 'account_management_passive'
+    throttle_scope = "account_management_passive"

+ 24 - 16
api/desecapi/views/domains.py

@@ -14,20 +14,22 @@ from desecapi.serializers import DomainSerializer
 from .base import IdempotentDestroyMixin
 from .base import IdempotentDestroyMixin
 
 
 
 
-class DomainViewSet(IdempotentDestroyMixin,
-                    mixins.CreateModelMixin,
-                    mixins.RetrieveModelMixin,
-                    mixins.DestroyModelMixin,
-                    mixins.ListModelMixin,
-                    viewsets.GenericViewSet):
+class DomainViewSet(
+    IdempotentDestroyMixin,
+    mixins.CreateModelMixin,
+    mixins.RetrieveModelMixin,
+    mixins.DestroyModelMixin,
+    mixins.ListModelMixin,
+    viewsets.GenericViewSet,
+):
     serializer_class = DomainSerializer
     serializer_class = DomainSerializer
-    lookup_field = 'name'
-    lookup_value_regex = r'[^/]+'
+    lookup_field = "name"
+    lookup_value_regex = r"[^/]+"
 
 
     @property
     @property
     def permission_classes(self):
     def permission_classes(self):
         ret = [IsAuthenticated, permissions.IsOwner]
         ret = [IsAuthenticated, permissions.IsOwner]
-        if self.action == 'create':
+        if self.action == "create":
             ret.append(permissions.WithinDomainLimit)
             ret.append(permissions.WithinDomainLimit)
         if self.request.method not in SAFE_METHODS:
         if self.request.method not in SAFE_METHODS:
             ret.append(permissions.TokenNoDomainPolicy)
             ret.append(permissions.TokenNoDomainPolicy)
@@ -35,13 +37,17 @@ class DomainViewSet(IdempotentDestroyMixin,
 
 
     @property
     @property
     def throttle_scope(self):
     def throttle_scope(self):
-        return 'dns_api_read' if self.request.method in SAFE_METHODS else 'dns_api_write_domains'
+        return (
+            "dns_api_read"
+            if self.request.method in SAFE_METHODS
+            else "dns_api_write_domains"
+        )
 
 
     @property
     @property
     def pagination_class(self):
     def pagination_class(self):
         # Turn off pagination when filtering for covered qname, as pagination would re-order by `created` (not what we
         # Turn off pagination when filtering for covered qname, as pagination would re-order by `created` (not what we
         # want here) after taking a slice (that's forbidden anyway). But, we don't need pagination in this case anyways.
         # want here) after taking a slice (that's forbidden anyway). But, we don't need pagination in this case anyways.
-        if 'owns_qname' in self.request.query_params:
+        if "owns_qname" in self.request.query_params:
             return None
             return None
         else:
         else:
             return api_settings.DEFAULT_PAGINATION_CLASS
             return api_settings.DEFAULT_PAGINATION_CLASS
@@ -49,14 +55,14 @@ class DomainViewSet(IdempotentDestroyMixin,
     def get_queryset(self):
     def get_queryset(self):
         qs = self.request.user.domains
         qs = self.request.user.domains
 
 
-        owns_qname = self.request.query_params.get('owns_qname')
+        owns_qname = self.request.query_params.get("owns_qname")
         if owns_qname is not None:
         if owns_qname is not None:
-            qs = qs.filter_qname(owns_qname).order_by('-name_length')[:1]
+            qs = qs.filter_qname(owns_qname).order_by("-name_length")[:1]
 
 
         return qs
         return qs
 
 
     def get_serializer(self, *args, **kwargs):
     def get_serializer(self, *args, **kwargs):
-        include_keys = (self.action in ['create', 'retrieve'])
+        include_keys = self.action in ["create", "retrieve"]
         return super().get_serializer(*args, include_keys=include_keys, **kwargs)
         return super().get_serializer(*args, include_keys=include_keys, **kwargs)
 
 
     def perform_create(self, serializer):
     def perform_create(self, serializer):
@@ -83,10 +89,12 @@ class DomainViewSet(IdempotentDestroyMixin,
 
 
 class SerialListView(APIView):
 class SerialListView(APIView):
     permission_classes = (permissions.IsVPNClient,)
     permission_classes = (permissions.IsVPNClient,)
-    throttle_classes = []  # don't break slaves when they ask too often (our cached responses are cheap)
+    throttle_classes = (
+        []
+    )  # don't break slaves when they ask too often (our cached responses are cheap)
 
 
     def get(self, request, *args, **kwargs):
     def get(self, request, *args, **kwargs):
-        key = 'desecapi.views.serials'
+        key = "desecapi.views.serials"
         serials = cache.get(key)
         serials = cache.get(key)
         if serials is None:
         if serials is None:
             serials = get_serials()
             serials = get_serials()

+ 25 - 20
api/desecapi/views/donation.py

@@ -13,31 +13,36 @@ class DonationList(generics.CreateAPIView):
         instance = serializer.save()
         instance = serializer.save()
 
 
         context = {
         context = {
-            'donation': instance,
-            'creditoridentifier': settings.SEPA['CREDITOR_ID'],
-            'creditorname': settings.SEPA['CREDITOR_NAME'],
+            "donation": instance,
+            "creditoridentifier": settings.SEPA["CREDITOR_ID"],
+            "creditorname": settings.SEPA["CREDITOR_NAME"],
         }
         }
 
 
         # internal desec notification
         # internal desec notification
-        content_tmpl = get_template('emails/donation/desec-content.txt')
-        subject_tmpl = get_template('emails/donation/desec-subject.txt')
-        attachment_tmpl = get_template('emails/donation/desec-attachment-jameica.txt')
-        from_tmpl = get_template('emails/from.txt')
-        email = EmailMessage(subject_tmpl.render(context),
-                             content_tmpl.render(context),
-                             from_tmpl.render(context),
-                             [settings.DEFAULT_FROM_EMAIL],
-                             attachments=[('jameica-directdebit.xml', attachment_tmpl.render(context), 'text/xml')],
-                             reply_to=[instance.email] if instance.email else None
-                             )
+        content_tmpl = get_template("emails/donation/desec-content.txt")
+        subject_tmpl = get_template("emails/donation/desec-subject.txt")
+        attachment_tmpl = get_template("emails/donation/desec-attachment-jameica.txt")
+        from_tmpl = get_template("emails/from.txt")
+        email = EmailMessage(
+            subject_tmpl.render(context),
+            content_tmpl.render(context),
+            from_tmpl.render(context),
+            [settings.DEFAULT_FROM_EMAIL],
+            attachments=[
+                ("jameica-directdebit.xml", attachment_tmpl.render(context), "text/xml")
+            ],
+            reply_to=[instance.email] if instance.email else None,
+        )
         email.send()
         email.send()
 
 
         # donor notification
         # donor notification
         if instance.email:
         if instance.email:
-            content_tmpl = get_template('emails/donation/donor-content.txt')
-            subject_tmpl = get_template('emails/donation/donor-subject.txt')
-            email = EmailMessage(subject_tmpl.render(context),
-                                 content_tmpl.render(context),
-                                 from_tmpl.render(context),
-                                 [instance.email])
+            content_tmpl = get_template("emails/donation/donor-content.txt")
+            subject_tmpl = get_template("emails/donation/donor-subject.txt")
+            email = EmailMessage(
+                subject_tmpl.render(context),
+                content_tmpl.render(context),
+                from_tmpl.render(context),
+                [instance.email],
+            )
             email.send()
             email.send()

+ 70 - 35
api/desecapi/views/dyndns.py

@@ -8,7 +8,11 @@ from rest_framework.exceptions import NotFound, ValidationError
 from rest_framework.response import Response
 from rest_framework.response import Response
 
 
 from desecapi import metrics
 from desecapi import metrics
-from desecapi.authentication import BasicTokenAuthentication, TokenAuthentication, URLParamAuthentication
+from desecapi.authentication import (
+    BasicTokenAuthentication,
+    TokenAuthentication,
+    URLParamAuthentication,
+)
 from desecapi.exceptions import ConcurrencyException
 from desecapi.exceptions import ConcurrencyException
 from desecapi.models import Domain
 from desecapi.models import Domain
 from desecapi.pdns_change_tracker import PDNSChangeTracker
 from desecapi.pdns_change_tracker import PDNSChangeTracker
@@ -18,11 +22,15 @@ from desecapi.serializers import RRsetSerializer
 
 
 
 
 class DynDNS12UpdateView(generics.GenericAPIView):
 class DynDNS12UpdateView(generics.GenericAPIView):
-    authentication_classes = (TokenAuthentication, BasicTokenAuthentication, URLParamAuthentication,)
+    authentication_classes = (
+        TokenAuthentication,
+        BasicTokenAuthentication,
+        URLParamAuthentication,
+    )
     permission_classes = (TokenHasDomainDynDNSPermission,)
     permission_classes = (TokenHasDomainDynDNSPermission,)
     renderer_classes = [PlainTextRenderer]
     renderer_classes = [PlainTextRenderer]
     serializer_class = RRsetSerializer
     serializer_class = RRsetSerializer
-    throttle_scope = 'dyndns'
+    throttle_scope = "dyndns"
 
 
     @property
     @property
     def throttle_scope_bucket(self):
     def throttle_scope_bucket(self):
@@ -30,9 +38,9 @@ class DynDNS12UpdateView(generics.GenericAPIView):
 
 
     def _find_ip(self, params, version):
     def _find_ip(self, params, version):
         if version == 4:
         if version == 4:
-            look_for = '.'
+            look_for = "."
         elif version == 6:
         elif version == 6:
-            look_for = ':'
+            look_for = ":"
         else:
         else:
             raise Exception
             raise Exception
 
 
@@ -45,7 +53,7 @@ class DynDNS12UpdateView(generics.GenericAPIView):
                     return self.request.query_params[p]
                     return self.request.query_params[p]
 
 
         # Check remote IP address
         # Check remote IP address
-        client_ip = self.request.META.get('REMOTE_ADDR')
+        client_ip = self.request.META.get("REMOTE_ADDR")
         if look_for in client_ip:
         if look_for in client_ip:
             return client_ip
             return client_ip
 
 
@@ -56,29 +64,37 @@ class DynDNS12UpdateView(generics.GenericAPIView):
     def qname(self):
     def qname(self):
         # hostname parameter
         # hostname parameter
         try:
         try:
-            if self.request.query_params['hostname'] != 'YES':
-                return self.request.query_params['hostname'].lower()
+            if self.request.query_params["hostname"] != "YES":
+                return self.request.query_params["hostname"].lower()
         except KeyError:
         except KeyError:
             pass
             pass
 
 
         # host_id parameter
         # host_id parameter
         try:
         try:
-            return self.request.query_params['host_id'].lower()
+            return self.request.query_params["host_id"].lower()
         except KeyError:
         except KeyError:
             pass
             pass
 
 
         # http basic auth username
         # http basic auth username
         try:
         try:
-            domain_name = base64.b64decode(
-                get_authorization_header(self.request).decode().split(' ')[1].encode()).decode().split(':')[0]
-            if domain_name and '@' not in domain_name:
+            domain_name = (
+                base64.b64decode(
+                    get_authorization_header(self.request)
+                    .decode()
+                    .split(" ")[1]
+                    .encode()
+                )
+                .decode()
+                .split(":")[0]
+            )
+            if domain_name and "@" not in domain_name:
                 return domain_name.lower()
                 return domain_name.lower()
         except (binascii.Error, IndexError, UnicodeDecodeError):
         except (binascii.Error, IndexError, UnicodeDecodeError):
             pass
             pass
 
 
         # username parameter
         # username parameter
         try:
         try:
-            return self.request.query_params['username'].lower()
+            return self.request.query_params["username"].lower()
         except KeyError:
         except KeyError:
             pass
             pass
 
 
@@ -86,40 +102,60 @@ class DynDNS12UpdateView(generics.GenericAPIView):
         try:
         try:
             return self.request.user.domains.get().name
             return self.request.user.domains.get().name
         except Domain.MultipleObjectsReturned:
         except Domain.MultipleObjectsReturned:
-            raise ValidationError(detail={
-                "detail": "Request does not properly specify domain for update.",
-                "code": "domain-unspecified"
-            })
+            raise ValidationError(
+                detail={
+                    "detail": "Request does not properly specify domain for update.",
+                    "code": "domain-unspecified",
+                }
+            )
         except Domain.DoesNotExist:
         except Domain.DoesNotExist:
-            metrics.get('desecapi_dynDNS12_domain_not_found').inc()
-            raise NotFound('nohost')
+            metrics.get("desecapi_dynDNS12_domain_not_found").inc()
+            raise NotFound("nohost")
 
 
     @cached_property
     @cached_property
     def domain(self):
     def domain(self):
         try:
         try:
-            return Domain.objects.filter_qname(self.qname, owner=self.request.user).order_by('-name_length')[0]
+            return Domain.objects.filter_qname(
+                self.qname, owner=self.request.user
+            ).order_by("-name_length")[0]
         except (IndexError, ValueError):
         except (IndexError, ValueError):
-            raise NotFound('nohost')
+            raise NotFound("nohost")
 
 
     @property
     @property
     def subname(self):
     def subname(self):
-        return self.qname.rpartition(f'.{self.domain.name}')[0]
+        return self.qname.rpartition(f".{self.domain.name}")[0]
 
 
     def get_serializer_context(self):
     def get_serializer_context(self):
-        return {**super().get_serializer_context(), 'domain': self.domain, 'minimum_ttl': 60}
+        return {
+            **super().get_serializer_context(),
+            "domain": self.domain,
+            "minimum_ttl": 60,
+        }
 
 
     def get_queryset(self):
     def get_queryset(self):
-        return self.domain.rrset_set.filter(subname=self.subname, type__in=['A', 'AAAA'])
+        return self.domain.rrset_set.filter(
+            subname=self.subname, type__in=["A", "AAAA"]
+        )
 
 
     def get(self, request, *args, **kwargs):
     def get(self, request, *args, **kwargs):
         instances = self.get_queryset().all()
         instances = self.get_queryset().all()
 
 
-        ipv4 = self._find_ip(['myip', 'myipv4', 'ip'], version=4)
-        ipv6 = self._find_ip(['myipv6', 'ipv6', 'myip', 'ip'], version=6)
+        ipv4 = self._find_ip(["myip", "myipv4", "ip"], version=4)
+        ipv6 = self._find_ip(["myipv6", "ipv6", "myip", "ip"], version=6)
 
 
         data = [
         data = [
-            {'type': 'A', 'subname': self.subname, 'ttl': 60, 'records': [ipv4] if ipv4 else []},
-            {'type': 'AAAA', 'subname': self.subname, 'ttl': 60, 'records': [ipv6] if ipv6 else []},
+            {
+                "type": "A",
+                "subname": self.subname,
+                "ttl": 60,
+                "records": [ipv4] if ipv4 else [],
+            },
+            {
+                "type": "AAAA",
+                "subname": self.subname,
+                "ttl": 60,
+                "records": [ipv6] if ipv6 else [],
+            },
         ]
         ]
 
 
         serializer = self.get_serializer(instances, data=data, many=True, partial=True)
         serializer = self.get_serializer(instances, data=data, many=True, partial=True)
@@ -127,12 +163,11 @@ class DynDNS12UpdateView(generics.GenericAPIView):
             serializer.is_valid(raise_exception=True)
             serializer.is_valid(raise_exception=True)
         except ValidationError as e:
         except ValidationError as e:
             if any(
             if any(
-                    any(
-                        getattr(non_field_error, 'code', '') == 'unique'
-                        for non_field_error
-                        in err.get('non_field_errors', [])
-                    )
-                    for err in e.detail
+                any(
+                    getattr(non_field_error, "code", "") == "unique"
+                    for non_field_error in err.get("non_field_errors", [])
+                )
+                for err in e.detail
             ):
             ):
                 raise ConcurrencyException from e
                 raise ConcurrencyException from e
             raise e
             raise e
@@ -140,4 +175,4 @@ class DynDNS12UpdateView(generics.GenericAPIView):
         with PDNSChangeTracker():
         with PDNSChangeTracker():
             serializer.save()
             serializer.save()
 
 
-        return Response('good', content_type='text/plain')
+        return Response("good", content_type="text/plain")

+ 37 - 20
api/desecapi/views/records.py

@@ -31,42 +31,51 @@ class EmptyPayloadMixin:
 
 
 class RRsetView:
 class RRsetView:
     serializer_class = RRsetSerializer
     serializer_class = RRsetSerializer
-    permission_classes = (IsAuthenticated, permissions.IsDomainOwner, permissions.TokenHasDomainRRsetsPermission,)
+    permission_classes = (
+        IsAuthenticated,
+        permissions.IsDomainOwner,
+        permissions.TokenHasDomainRRsetsPermission,
+    )
 
 
     @property
     @property
     def domain(self):
     def domain(self):
         try:
         try:
-            return self.request.user.domains.get(name=self.kwargs['name'])
+            return self.request.user.domains.get(name=self.kwargs["name"])
         except models.Domain.DoesNotExist:
         except models.Domain.DoesNotExist:
             raise Http404
             raise Http404
 
 
     @property
     @property
     def throttle_scope(self):
     def throttle_scope(self):
-        return 'dns_api_read' if self.request.method in SAFE_METHODS else 'dns_api_write_rrsets'
+        return (
+            "dns_api_read"
+            if self.request.method in SAFE_METHODS
+            else "dns_api_write_rrsets"
+        )
 
 
     @property
     @property
     def throttle_scope_bucket(self):
     def throttle_scope_bucket(self):
         # Note: bucket should remain constant even when domain is recreated
         # Note: bucket should remain constant even when domain is recreated
-        return None if self.request.method in SAFE_METHODS else self.kwargs['name']
+        return None if self.request.method in SAFE_METHODS else self.kwargs["name"]
 
 
     def get_queryset(self):
     def get_queryset(self):
         return self.domain.rrset_set
         return self.domain.rrset_set
 
 
     def get_serializer_context(self):
     def get_serializer_context(self):
         # noinspection PyUnresolvedReferences
         # noinspection PyUnresolvedReferences
-        return {**super().get_serializer_context(), 'domain': self.domain}
+        return {**super().get_serializer_context(), "domain": self.domain}
 
 
     def perform_update(self, serializer):
     def perform_update(self, serializer):
         with PDNSChangeTracker():
         with PDNSChangeTracker():
             super().perform_update(serializer)
             super().perform_update(serializer)
 
 
 
 
-class RRsetDetail(RRsetView, IdempotentDestroyMixin, generics.RetrieveUpdateDestroyAPIView):
-
+class RRsetDetail(
+    RRsetView, IdempotentDestroyMixin, generics.RetrieveUpdateDestroyAPIView
+):
     def get_object(self):
     def get_object(self):
         queryset = self.filter_queryset(self.get_queryset())
         queryset = self.filter_queryset(self.get_queryset())
 
 
-        filter_kwargs = {k: self.kwargs[k] for k in ['subname', 'type']}
+        filter_kwargs = {k: self.kwargs[k] for k in ["subname", "type"]}
         obj = generics.get_object_or_404(queryset, **filter_kwargs)
         obj = generics.get_object_or_404(queryset, **filter_kwargs)
 
 
         # May raise a permission denied
         # May raise a permission denied
@@ -86,22 +95,30 @@ class RRsetDetail(RRsetView, IdempotentDestroyMixin, generics.RetrieveUpdateDest
             super().perform_destroy(instance)
             super().perform_destroy(instance)
 
 
 
 
-class RRsetList(RRsetView, EmptyPayloadMixin, generics.ListCreateAPIView, generics.UpdateAPIView):
-
+class RRsetList(
+    RRsetView, EmptyPayloadMixin, generics.ListCreateAPIView, generics.UpdateAPIView
+):
     def get_queryset(self):
     def get_queryset(self):
         rrsets = super().get_queryset()
         rrsets = super().get_queryset()
 
 
-        for filter_field in ('subname', 'type'):
+        for filter_field in ("subname", "type"):
             value = self.request.query_params.get(filter_field)
             value = self.request.query_params.get(filter_field)
 
 
             if value is not None:
             if value is not None:
                 # TODO consider moving this
                 # TODO consider moving this
-                if filter_field == 'type' and value in models.records.RR_SET_TYPES_AUTOMATIC:
-                    raise PermissionDenied("You cannot tinker with the %s RRset." % value)
+                if (
+                    filter_field == "type"
+                    and value in models.records.RR_SET_TYPES_AUTOMATIC
+                ):
+                    raise PermissionDenied(
+                        "You cannot tinker with the %s RRset." % value
+                    )
 
 
-                rrsets = rrsets.filter(**{'%s__exact' % filter_field: value})
+                rrsets = rrsets.filter(**{"%s__exact" % filter_field: value})
 
 
-        return rrsets.all()  # without .all(), cache is sometimes inconsistent with actual state in bulk tests. (Why?)
+        return (
+            rrsets.all()
+        )  # without .all(), cache is sometimes inconsistent with actual state in bulk tests. (Why?)
 
 
     def get_object(self):
     def get_object(self):
         # For this view, the object we're operating on is the queryset that one can also GET. Serializing a queryset
         # For this view, the object we're operating on is the queryset that one can also GET. Serializing a queryset
@@ -113,11 +130,11 @@ class RRsetList(RRsetView, EmptyPayloadMixin, generics.ListCreateAPIView, generi
     def get_serializer(self, *args, **kwargs):
     def get_serializer(self, *args, **kwargs):
         kwargs = kwargs.copy()
         kwargs = kwargs.copy()
 
 
-        if 'many' not in kwargs:
-            if self.request.method in ['POST']:
-                kwargs['many'] = isinstance(kwargs.get('data'), list)
-            elif self.request.method in ['PATCH', 'PUT']:
-                kwargs['many'] = True
+        if "many" not in kwargs:
+            if self.request.method in ["POST"]:
+                kwargs["many"] = isinstance(kwargs.get("data"), list)
+            elif self.request.method in ["PATCH", "PUT"]:
+                kwargs["many"] = True
 
 
         return super().get_serializer(*args, **kwargs)
         return super().get_serializer(*args, **kwargs)
 
 

+ 27 - 12
api/desecapi/views/tokens.py

@@ -16,16 +16,19 @@ from .domains import DomainViewSet
 
 
 class TokenViewSet(IdempotentDestroyMixin, viewsets.ModelViewSet):
 class TokenViewSet(IdempotentDestroyMixin, viewsets.ModelViewSet):
     serializer_class = TokenSerializer
     serializer_class = TokenSerializer
-    permission_classes = (IsAuthenticated, permissions.HasManageTokensPermission,)
-    throttle_scope = 'account_management_passive'
+    permission_classes = (
+        IsAuthenticated,
+        permissions.HasManageTokensPermission,
+    )
+    throttle_scope = "account_management_passive"
 
 
     def get_queryset(self):
     def get_queryset(self):
         return self.request.user.token_set.all()
         return self.request.user.token_set.all()
 
 
     def get_serializer(self, *args, **kwargs):
     def get_serializer(self, *args, **kwargs):
         # When creating a new token, return the plaintext representation
         # When creating a new token, return the plaintext representation
-        if self.action == 'create':
-            kwargs.setdefault('include_plain', True)
+        if self.action == "create":
+            kwargs.setdefault("include_plain", True)
         return super().get_serializer(*args, **kwargs)
         return super().get_serializer(*args, **kwargs)
 
 
     def perform_create(self, serializer):
     def perform_create(self, serializer):
@@ -35,25 +38,35 @@ class TokenViewSet(IdempotentDestroyMixin, viewsets.ModelViewSet):
 class TokenPoliciesRoot(APIView):
 class TokenPoliciesRoot(APIView):
     permission_classes = [
     permission_classes = [
         IsAuthenticated,
         IsAuthenticated,
-        permissions.HasManageTokensPermission | permissions.AuthTokenCorrespondsToViewToken,
+        permissions.HasManageTokensPermission
+        | permissions.AuthTokenCorrespondsToViewToken,
     ]
     ]
 
 
     def get(self, request, *args, **kwargs):
     def get(self, request, *args, **kwargs):
-        return Response({'domain': reverse('token_domain_policies-list', request=request, kwargs=kwargs)})
+        return Response(
+            {
+                "domain": reverse(
+                    "token_domain_policies-list", request=request, kwargs=kwargs
+                )
+            }
+        )
 
 
 
 
 class TokenDomainPolicyViewSet(IdempotentDestroyMixin, viewsets.ModelViewSet):
 class TokenDomainPolicyViewSet(IdempotentDestroyMixin, viewsets.ModelViewSet):
-    lookup_field = 'domain__name'
+    lookup_field = "domain__name"
     lookup_value_regex = DomainViewSet.lookup_value_regex
     lookup_value_regex = DomainViewSet.lookup_value_regex
     pagination_class = None
     pagination_class = None
     serializer_class = TokenDomainPolicySerializer
     serializer_class = TokenDomainPolicySerializer
-    throttle_scope = 'account_management_passive'
+    throttle_scope = "account_management_passive"
 
 
     @property
     @property
     def permission_classes(self):
     def permission_classes(self):
         ret = [IsAuthenticated]
         ret = [IsAuthenticated]
         if self.request.method in SAFE_METHODS:
         if self.request.method in SAFE_METHODS:
-            ret.append(permissions.HasManageTokensPermission | permissions.AuthTokenCorrespondsToViewToken)
+            ret.append(
+                permissions.HasManageTokensPermission
+                | permissions.AuthTokenCorrespondsToViewToken
+            )
         else:
         else:
             ret.append(permissions.HasManageTokensPermission)
             ret.append(permissions.HasManageTokensPermission)
         return ret
         return ret
@@ -62,17 +75,19 @@ class TokenDomainPolicyViewSet(IdempotentDestroyMixin, viewsets.ModelViewSet):
         # map default policy onto domain_id IS NULL
         # map default policy onto domain_id IS NULL
         lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
         lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
         try:
         try:
-            if kwargs[lookup_url_kwarg] == 'default':
+            if kwargs[lookup_url_kwarg] == "default":
                 kwargs[lookup_url_kwarg] = None
                 kwargs[lookup_url_kwarg] = None
         except KeyError:
         except KeyError:
             pass
             pass
         return super().dispatch(request, *args, **kwargs)
         return super().dispatch(request, *args, **kwargs)
 
 
     def get_queryset(self):
     def get_queryset(self):
-        return TokenDomainPolicy.objects.filter(token_id=self.kwargs['token_id'], token__user=self.request.user)
+        return TokenDomainPolicy.objects.filter(
+            token_id=self.kwargs["token_id"], token__user=self.request.user
+        )
 
 
     def perform_destroy(self, instance):
     def perform_destroy(self, instance):
         try:
         try:
             super().perform_destroy(instance)
             super().perform_destroy(instance)
         except django.core.exceptions.ValidationError as exc:
         except django.core.exceptions.ValidationError as exc:
-            raise ValidationError(exc.message_dict, code='precedence')
+            raise ValidationError(exc.message_dict, code="precedence")

Some files were not shown because too many files changed in this diff