feat: django-rest-framework + Person/Email API (#8256)

* feat: django-rest-framework + Person/Email API (#8233)

* chore: djangorestframework -> requirements.txt

* chore: auth/perm/schema classes for drf

* chore: settings for drf and friends

* chore: comment that api/serializer.py is not DRF

* feat: URL router for DRF

* feat: simple api/v3/person/{id} endpoint

* fix: actually working demo endpoint

* chore: no auth for PersonViewSet

* ci: params in ci-run-tests.yml

* Revert "ci: params in ci-run-tests.yml"

This reverts commit 03808ddf94afe42b7382ddd3730959987389612b.

* feat: email addresses for person API

* feat: email update api (WIP)

* fix: working Email API endpoint

* chore: annotate address format in api schema

* chore: api adjustments

* feat: expose SpectacularAPIView

At least for now...

* chore: better schema_path_prefix

* feat: permissions for DRF API

* refactor: use permissions classes

* refactor: extract NewEmailForm validation for reuse

* refactor: ietfauth.validators module

* refactor: send new email conf req via helper

* feat: API call to issue new address request

* chore: move datatracker DRF api to /api/core/

* fix: unused import

* fix: lint

* test: drf URL names + API tests (#8248)

* refactor: better drf URL naming

* test: test person-detail view

* test: permissions

* test: add_email tests + stubs

* test: test email update

* test: test 404 vs 403

* fix: fix permissions

* test: test email partial update

* test: assert we have a nonexistent PK

* chore: disable DRF api for now

* chore: fix git inanity

* fix: lint

* test: disable tests of disabled code

* test: more lint
This commit is contained in:
Jennifer Richards 2024-11-27 16:54:28 -04:00 committed by GitHub
parent c18900a8e6
commit c58490bb36
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 650 additions and 53 deletions

View file

@ -12,4 +12,8 @@ class ApiConfig(AppConfig):
interact with the database. See
https://docs.djangoproject.com/en/4.2/ref/applications/#django.apps.AppConfig.ready
"""
# Populate our API list now that the app registry is set up
populate_api_list()
# Import drf-spectacular extensions
import ietf.api.schema # pyflakes: ignore

View file

@ -0,0 +1,19 @@
# Copyright The IETF Trust 2024, All Rights Reserved
#
from rest_framework import authentication
from django.contrib.auth.models import AnonymousUser
class ApiKeyAuthentication(authentication.BaseAuthentication):
"""API-Key header authentication"""
def authenticate(self, request):
"""Extract the authentication token, if present
This does not validate the token, it just arranges for it to be available in request.auth.
It's up to a Permissions class to validate it for the appropriate endpoint.
"""
token = request.META.get("HTTP_X_API_KEY", None)
if token is None:
return None
return AnonymousUser(), token # available as request.user and request.auth

39
ietf/api/permissions.py Normal file
View file

@ -0,0 +1,39 @@
# Copyright The IETF Trust 2024, All Rights Reserved
#
from rest_framework import permissions
from ietf.api.ietf_utils import is_valid_token
class HasApiKey(permissions.BasePermission):
"""Permissions class that validates a token using is_valid_token
The view class must indicate the relevant endpoint by setting `api_key_endpoint`.
Must be used with an Authentication class that puts a token in request.auth.
"""
def has_permission(self, request, view):
endpoint = getattr(view, "api_key_endpoint", None)
auth_token = getattr(request, "auth", None)
if endpoint is not None and auth_token is not None:
return is_valid_token(endpoint, auth_token)
return False
class IsOwnPerson(permissions.BasePermission):
"""Permission to access own Person object"""
def has_object_permission(self, request, view, obj):
if not (request.user.is_authenticated and hasattr(request.user, "person")):
return False
return obj == request.user.person
class BelongsToOwnPerson(permissions.BasePermission):
"""Permission to access objects associated with own Person
Requires that the object have a "person" field that indicates ownership.
"""
def has_object_permission(self, request, view, obj):
if not (request.user.is_authenticated and hasattr(request.user, "person")):
return False
return (
hasattr(obj, "person") and obj.person == request.user.person
)

16
ietf/api/routers.py Normal file
View file

@ -0,0 +1,16 @@
# Copyright The IETF Trust 2024, All Rights Reserved
"""Custom django-rest-framework routers"""
from django.core.exceptions import ImproperlyConfigured
from rest_framework import routers
class PrefixedSimpleRouter(routers.SimpleRouter):
"""SimpleRouter that adds a dot-separated prefix to its basename"""
def __init__(self, name_prefix="", *args, **kwargs):
self.name_prefix = name_prefix
if len(self.name_prefix) == 0 or self.name_prefix[-1] == ".":
raise ImproperlyConfigured("Cannot use a name_prefix that is empty or ends with '.'")
super().__init__(*args, **kwargs)
def get_default_basename(self, viewset):
basename = super().get_default_basename(viewset)
return f"{self.name_prefix}.{basename}"

20
ietf/api/schema.py Normal file
View file

@ -0,0 +1,20 @@
# Copyright The IETF Trust 2024, All Rights Reserved
#
from drf_spectacular.extensions import OpenApiAuthenticationExtension
class ApiKeyAuthenticationScheme(OpenApiAuthenticationExtension):
"""Authentication scheme extension for the ApiKeyAuthentication
Used by drf-spectacular when rendering the OpenAPI schema
"""
target_class = "ietf.api.authentication.ApiKeyAuthentication"
name = "apiKeyAuth"
def get_security_definition(self, auto_schema):
return {
"type": "apiKey",
"description": "Shared secret in the X-Api-Key header",
"name": "X-Api-Key",
"in": "header",
}

View file

@ -1,6 +1,9 @@
# Copyright The IETF Trust 2018-2020, All Rights Reserved
# Copyright The IETF Trust 2018-2024, All Rights Reserved
# -*- coding: utf-8 -*-
"""Serialization utilities
This is _not_ for django-rest-framework!
"""
import hashlib
import json

289
ietf/api/tests_core.py Normal file
View file

@ -0,0 +1,289 @@
# Copyright The IETF Trust 2024, All Rights Reserved
"""Core API tests"""
from unittest.mock import patch
# from unittest.mock import patch, call
from django.urls import reverse as urlreverse, NoReverseMatch
from rest_framework.test import APIClient
# from ietf.person.factories import PersonFactory, EmailFactory
# from ietf.person.models import Person
from ietf.utils.test_utils import TestCase
class CoreApiTestCase(TestCase):
client_class = APIClient
class PersonTests(CoreApiTestCase):
# Tests disabled until we activate the DRF URLs in api/urls.py
def test_person_detail(self):
with self.assertRaises(NoReverseMatch, msg="Re-enable test when this view is enabled"):
urlreverse("ietf.api.core_api.person-detail", kwargs={"pk": 1})
# person = PersonFactory()
# other_person = PersonFactory()
# url = urlreverse("ietf.api.core_api.person-detail", kwargs={"pk": person.pk})
# bad_pk = person.pk + 10000
# if Person.objects.filter(pk=bad_pk).exists():
# bad_pk += 10000 # if this doesn't get us clear, something is wrong...
# self.assertFalse(
# Person.objects.filter(pk=bad_pk).exists(),
# "Failed to find a non-existent person pk",
# )
# bad_url = urlreverse("ietf.api.core_api.person-detail", kwargs={"pk": bad_pk})
# r = self.client.get(bad_url, format="json")
# self.assertEqual(r.status_code, 403, "Must be logged in preferred to 404")
# r = self.client.get(url, format="json")
# self.assertEqual(r.status_code, 403, "Must be logged in")
# self.client.login(
# username=other_person.user.username,
# password=other_person.user.username + "+password",
# )
# r = self.client.get(bad_url, format="json")
# self.assertEqual(r.status_code, 404)
# r = self.client.get(url, format="json")
# self.assertEqual(r.status_code, 403, "Can only retrieve self")
# self.client.login(
# username=person.user.username, password=person.user.username + "+password"
# )
# r = self.client.get(url, format="json")
# self.assertEqual(r.status_code, 200)
# self.assertEqual(
# r.data,
# {
# "id": person.pk,
# "name": person.name,
# "emails": [
# {
# "person": person.pk,
# "address": email.address,
# "primary": email.primary,
# "active": email.active,
# "origin": email.origin,
# }
# for email in person.email_set.all()
# ],
# },
# )
@patch("ietf.person.api.send_new_email_confirmation_request")
def test_add_email(self, send_confirmation_mock):
with self.assertRaises(NoReverseMatch, msg="Re-enable this test when this view is enabled"):
urlreverse("ietf.api.core_api.person-email", kwargs={"pk": 1})
# email = EmailFactory(address="old@example.org")
# person = email.person
# other_person = PersonFactory()
# url = urlreverse("ietf.api.core_api.person-email", kwargs={"pk": person.pk})
# post_data = {"address": "new@example.org"}
#
# r = self.client.post(url, data=post_data, format="json")
# self.assertEqual(r.status_code, 403, "Must be logged in")
# self.assertFalse(send_confirmation_mock.called)
#
# self.client.login(
# username=other_person.user.username,
# password=other_person.user.username + "+password",
# )
# r = self.client.post(url, data=post_data, format="json")
# self.assertEqual(r.status_code, 403, "Can only retrieve self")
# self.assertFalse(send_confirmation_mock.called)
#
# self.client.login(
# username=person.user.username, password=person.user.username + "+password"
# )
# r = self.client.post(url, data=post_data, format="json")
# self.assertEqual(r.status_code, 200)
# self.assertEqual(r.data, {"address": "new@example.org"})
# self.assertTrue(send_confirmation_mock.called)
# self.assertEqual(
# send_confirmation_mock.call_args, call(person, "new@example.org")
# )
class EmailTests(CoreApiTestCase):
def test_email_update(self):
with self.assertRaises(NoReverseMatch, msg="Re-enable this test when the view is enabled"):
urlreverse(
"ietf.api.core_api.email-detail", kwargs={"pk": "original@example.org"}
)
# email = EmailFactory(
# address="original@example.org", primary=False, active=True, origin="factory"
# )
# person = email.person
# other_person = PersonFactory()
# url = urlreverse(
# "ietf.api.core_api.email-detail", kwargs={"pk": "original@example.org"}
# )
# bad_url = urlreverse(
# "ietf.api.core_api.email-detail",
# kwargs={"pk": "not-original@example.org"},
# )
#
# r = self.client.put(
# bad_url, data={"primary": True, "active": False}, format="json"
# )
# self.assertEqual(r.status_code, 403, "Must be logged in preferred to 404")
# r = self.client.put(url, data={"primary": True, "active": False}, format="json")
# self.assertEqual(r.status_code, 403, "Must be logged in")
#
# self.client.login(
# username=other_person.user.username,
# password=other_person.user.username + "+password",
# )
# r = self.client.put(
# bad_url, data={"primary": True, "active": False}, format="json"
# )
# self.assertEqual(r.status_code, 404, "No such address")
# r = self.client.put(url, data={"primary": True, "active": False}, format="json")
# self.assertEqual(r.status_code, 403, "Can only access own addresses")
#
# self.client.login(
# username=person.user.username, password=person.user.username + "+password"
# )
# r = self.client.put(url, data={"primary": True, "active": False}, format="json")
# self.assertEqual(r.status_code, 200)
# self.assertEqual(
# r.data,
# {
# "person": person.pk,
# "address": "original@example.org",
# "primary": True,
# "active": False,
# "origin": "factory",
# },
# )
# email.refresh_from_db()
# self.assertEqual(email.person, person)
# self.assertEqual(email.address, "original@example.org")
# self.assertTrue(email.primary)
# self.assertFalse(email.active)
# self.assertEqual(email.origin, "factory")
#
# # address / origin should be immutable
# r = self.client.put(
# url,
# data={
# "address": "modified@example.org",
# "primary": True,
# "active": False,
# "origin": "hacker",
# },
# format="json",
# )
# self.assertEqual(r.status_code, 200)
# self.assertEqual(
# r.data,
# {
# "person": person.pk,
# "address": "original@example.org",
# "primary": True,
# "active": False,
# "origin": "factory",
# },
# )
# email.refresh_from_db()
# self.assertEqual(email.person, person)
# self.assertEqual(email.address, "original@example.org")
# self.assertTrue(email.primary)
# self.assertFalse(email.active)
# self.assertEqual(email.origin, "factory")
def test_email_partial_update(self):
with self.assertRaises(NoReverseMatch, msg="Re-enable this test when the view is enabled"):
urlreverse(
"ietf.api.core_api.email-detail", kwargs={"pk": "original@example.org"}
)
# email = EmailFactory(
# address="original@example.org", primary=False, active=True, origin="factory"
# )
# person = email.person
# other_person = PersonFactory()
# url = urlreverse(
# "ietf.api.core_api.email-detail", kwargs={"pk": "original@example.org"}
# )
# bad_url = urlreverse(
# "ietf.api.core_api.email-detail",
# kwargs={"pk": "not-original@example.org"},
# )
#
# r = self.client.patch(
# bad_url, data={"primary": True}, format="json"
# )
# self.assertEqual(r.status_code, 403, "Must be logged in preferred to 404")
# r = self.client.patch(url, data={"primary": True}, format="json")
# self.assertEqual(r.status_code, 403, "Must be logged in")
#
# self.client.login(
# username=other_person.user.username,
# password=other_person.user.username + "+password",
# )
# r = self.client.patch(
# bad_url, data={"primary": True}, format="json"
# )
# self.assertEqual(r.status_code, 404, "No such address")
# r = self.client.patch(url, data={"primary": True}, format="json")
# self.assertEqual(r.status_code, 403, "Can only access own addresses")
#
# self.client.login(
# username=person.user.username, password=person.user.username + "+password"
# )
# r = self.client.patch(url, data={"primary": True}, format="json")
# self.assertEqual(r.status_code, 200)
# self.assertEqual(
# r.data,
# {
# "person": person.pk,
# "address": "original@example.org",
# "primary": True,
# "active": True,
# "origin": "factory",
# },
# )
# email.refresh_from_db()
# self.assertEqual(email.person, person)
# self.assertEqual(email.address, "original@example.org")
# self.assertTrue(email.primary)
# self.assertTrue(email.active)
# self.assertEqual(email.origin, "factory")
#
# r = self.client.patch(url, data={"active": False}, format="json")
# self.assertEqual(r.status_code, 200)
# self.assertEqual(
# r.data,
# {
# "person": person.pk,
# "address": "original@example.org",
# "primary": True,
# "active": False,
# "origin": "factory",
# },
# )
# email.refresh_from_db()
# self.assertEqual(email.person, person)
# self.assertEqual(email.address, "original@example.org")
# self.assertTrue(email.primary)
# self.assertFalse(email.active)
# self.assertEqual(email.origin, "factory")
#
# r = self.client.patch(url, data={"address": "modified@example.org"}, format="json")
# self.assertEqual(r.status_code, 200) # extra fields allowed, but ignored
# email.refresh_from_db()
# self.assertEqual(email.person, person)
# self.assertEqual(email.address, "original@example.org")
# self.assertTrue(email.primary)
# self.assertFalse(email.active)
# self.assertEqual(email.origin, "factory")
#
# r = self.client.patch(url, data={"origin": "hacker"}, format="json")
# self.assertEqual(r.status_code, 200) # extra fields allowed, but ignored
# email.refresh_from_db()
# self.assertEqual(email.person, person)
# self.assertEqual(email.address, "original@example.org")
# self.assertTrue(email.primary)
# self.assertFalse(email.active)
# self.assertEqual(email.origin, "factory")

View file

@ -5,12 +5,21 @@ from django.urls import include
from django.views.generic import TemplateView
from ietf import api
from ietf.api import views as api_views
from ietf.doc import views_ballot
from ietf.meeting import views as meeting_views
from ietf.submit import views as submit_views
from ietf.utils.urls import url
from . import views as api_views
# DRF API routing - disabled until we plan to use it
# from drf_spectacular.views import SpectacularAPIView
# from django.urls import path
# from ietf.person import api as person_api
# from .routers import PrefixedSimpleRouter
# core_router = PrefixedSimpleRouter(name_prefix="ietf.api.core_api") # core api router
# core_router.register("email", person_api.EmailViewSet)
# core_router.register("person", person_api.PersonViewSet)
api.autodiscover()
@ -21,6 +30,9 @@ urlpatterns = [
url(r'^v1/?$', api_views.top_level),
# For mailarchive use, requires secretariat role
url(r'^v2/person/person', api_views.ApiV2PersonExportView.as_view()),
# --- DRF API ---
# path("core/", include(core_router.urls)),
# path("schema/", SpectacularAPIView.as_view()),
#
# --- Custom API endpoints, sorted alphabetically ---
# Email alias information for drafts

View file

@ -6,17 +6,15 @@ import re
from unidecode import unidecode
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.contrib.auth.models import User
import debug # pyflakes:ignore
from ietf.person.models import Person, Email
from ietf.mailinglists.models import Allowlisted
from ietf.utils.text import isascii
from .validators import prevent_at_symbol, prevent_system_name, prevent_anonymous_name, is_allowed_address
from .widgets import PasswordStrengthInput, PasswordConfirmationInput
@ -53,19 +51,6 @@ def ascii_cleaner(supposedly_ascii):
raise forms.ValidationError("Only unaccented Latin characters are allowed.")
return supposedly_ascii
def prevent_at_symbol(name):
if "@" in name:
raise forms.ValidationError("Please fill in name - this looks like an email address (@ is not allowed in names).")
def prevent_system_name(name):
name_without_spaces = name.replace(" ", "").replace("\t", "")
if "(system)" in name_without_spaces.lower():
raise forms.ValidationError("Please pick another name - this name is reserved.")
def prevent_anonymous_name(name):
name_without_spaces = name.replace(" ", "").replace("\t", "")
if "anonymous" in name_without_spaces.lower():
raise forms.ValidationError("Please pick another name - this name is reserved.")
class PersonPasswordForm(forms.ModelForm, PasswordForm):
@ -156,15 +141,7 @@ def get_person_form(*args, **kwargs):
class NewEmailForm(forms.Form):
new_email = forms.EmailField(label="New email address", required=False)
def clean_new_email(self):
email = self.cleaned_data.get("new_email", "")
for pat in settings.EXCLUDED_PERSONAL_EMAIL_REGEX_PATTERNS:
if re.search(pat, email):
raise ValidationError("This email address is not valid in a datatracker account")
return email
new_email = forms.EmailField(label="New email address", required=False, validators=[is_allowed_address])
class RoleEmailForm(forms.Form):

View file

@ -12,6 +12,8 @@ from urllib.parse import quote as urlquote
from django.conf import settings
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.sites.models import Site
from django.core import signing
from django.core.exceptions import PermissionDenied
from django.db.models import Q
from django.http import HttpResponseRedirect
@ -20,9 +22,10 @@ from django.shortcuts import get_object_or_404
import debug # pyflakes:ignore
from ietf.group.models import Role, GroupFeatures
from ietf.person.models import Person
from ietf.person.models import Email, Person
from ietf.person.utils import get_dots
from ietf.doc.utils_bofreq import bofreq_editors
from ietf.utils.mail import send_mail
def user_is_person(user, person):
"""Test whether user is associated with person."""
@ -394,3 +397,47 @@ def can_request_rfc_publication(user, doc):
return False # See the docstring
else:
return False
def send_new_email_confirmation_request(person: Person, address: str):
"""Request confirmation of a new email address
If the email address is already in use, sends an alert to it. If not, sends a confirmation request.
By design, does not indicate which was sent. This is intended to make it a bit harder to scrape addresses
with a mindless bot.
"""
auth = signing.dumps([person.user.username, address], salt="add_email")
domain = Site.objects.get_current().domain
from_email = settings.DEFAULT_FROM_EMAIL
existing = Email.objects.filter(address=address).first()
if existing:
subject = f"Attempt to add your email address by {person.name}"
send_mail(
None,
address,
from_email,
subject,
"registration/add_email_exists_email.txt",
{
"domain": domain,
"email": address,
"person": person,
},
)
else:
subject = f"Confirm email address for {person.name}"
send_mail(
None,
address,
from_email,
subject,
"registration/add_email_email.txt",
{
"domain": domain,
"auth": auth,
"email": address,
"person": person,
"expire": settings.DAYS_TO_EXPIRE_REGISTRATION_LINK,
},
)

View file

@ -0,0 +1,34 @@
# Copyright The IETF Trust 2024, All Rights Reserved
import re
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
def prevent_at_symbol(name):
if "@" in name:
raise forms.ValidationError(
"Please fill in name - this looks like an email address (@ is not allowed in names)."
)
def prevent_system_name(name):
name_without_spaces = name.replace(" ", "").replace("\t", "")
if "(system)" in name_without_spaces.lower():
raise forms.ValidationError("Please pick another name - this name is reserved.")
def prevent_anonymous_name(name):
name_without_spaces = name.replace(" ", "").replace("\t", "")
if "anonymous" in name_without_spaces.lower():
raise forms.ValidationError("Please pick another name - this name is reserved.")
def is_allowed_address(value):
"""Validate that an address complies with datatracker requirements"""
for pat in settings.EXCLUDED_PERSONAL_EMAIL_REGEX_PATTERNS:
if re.search(pat, value):
raise ValidationError(
"This email address is not valid in a datatracker account"
)

View file

@ -65,7 +65,7 @@ from ietf.group.models import Role, Group
from ietf.ietfauth.forms import ( RegistrationForm, PasswordForm, ResetPasswordForm, TestEmailForm,
ChangePasswordForm, get_person_form, RoleEmailForm,
NewEmailForm, ChangeUsernameForm, PersonPasswordForm)
from ietf.ietfauth.utils import has_role
from ietf.ietfauth.utils import has_role, send_new_email_confirmation_request
from ietf.name.models import ExtResourceName
from ietf.nomcom.models import NomCom
from ietf.person.models import Person, Email, Alias, PersonalApiKey, PERSON_API_KEY_VALUES
@ -297,31 +297,8 @@ def profile(request):
to_email = f.cleaned_data["new_email"]
if not to_email:
continue
email_confirmations.append(to_email)
auth = django.core.signing.dumps([person.user.username, to_email], salt="add_email")
domain = Site.objects.get_current().domain
from_email = settings.DEFAULT_FROM_EMAIL
existing = Email.objects.filter(address=to_email).first()
if existing:
subject = 'Attempt to add your email address by %s' % person.name
send_mail(request, to_email, from_email, subject, 'registration/add_email_exists_email.txt', {
'domain': domain,
'email': to_email,
'person': person,
})
else:
subject = 'Confirm email address for %s' % person.name
send_mail(request, to_email, from_email, subject, 'registration/add_email_email.txt', {
'domain': domain,
'auth': auth,
'email': to_email,
'person': person,
'expire': settings.DAYS_TO_EXPIRE_REGISTRATION_LINK,
})
send_new_email_confirmation_request(person, to_email)
for r in roles:
e = r.email_form.cleaned_data["email"]

45
ietf/person/api.py Normal file
View file

@ -0,0 +1,45 @@
# Copyright The IETF Trust 2024, All Rights Reserved
"""DRF API Views"""
from rest_framework import mixins, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from ietf.api.permissions import BelongsToOwnPerson, IsOwnPerson
from ietf.ietfauth.utils import send_new_email_confirmation_request
from .models import Email, Person
from .serializers import NewEmailSerializer, EmailSerializer, PersonSerializer
class EmailViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
"""Email viewset
Only allows updating an existing email for now.
"""
permission_classes = [IsAuthenticated & BelongsToOwnPerson]
queryset = Email.objects.all()
serializer_class = EmailSerializer
lookup_value_regex = '.+@.+' # allow @-sign in the pk
class PersonViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
"""Person viewset"""
permission_classes = [IsAuthenticated & IsOwnPerson]
queryset = Person.objects.all()
serializer_class = PersonSerializer
@action(detail=True, methods=["post"], serializer_class=NewEmailSerializer)
def email(self, request, pk=None):
"""Add an email address for this Person
Always succeeds if the email address is valid. Causes a confirmation email to be sent to the
requested address and completion of that handshake will actually add the email address. If the
address already exists, an alert will be sent instead of the confirmation email.
"""
person = self.get_object()
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# This may or may not actually send a confirmation, but doesn't reveal that to the user.
send_new_email_confirmation_request(person, serializer.validated_data["address"])
return Response(serializer.data)

View file

@ -0,0 +1,39 @@
# Copyright The IETF Trust 2024, All Rights Reserved
"""DRF Serializers"""
from rest_framework import serializers
from ietf.ietfauth.validators import is_allowed_address
from .models import Email, Person
class EmailSerializer(serializers.ModelSerializer):
"""Email serializer for read/update"""
address = serializers.EmailField(read_only=True)
class Meta:
model = Email
fields = [
"person",
"address",
"primary",
"active",
"origin",
]
read_only_fields = ["person", "address", "origin"]
class NewEmailSerializer(serializers.Serializer):
"""Serialize a new email address request"""
address = serializers.EmailField(validators=[is_allowed_address])
class PersonSerializer(serializers.ModelSerializer):
"""Person serializer"""
emails = EmailSerializer(many=True, source="email_set")
class Meta:
model = Person
fields = ["id", "name", "emails"]

View file

@ -455,6 +455,9 @@ INSTALLED_APPS = [
'corsheaders',
'django_markup',
'oidc_provider',
'drf_spectacular',
'drf_standardized_errors',
'rest_framework',
'simple_history',
'tastypie',
'widget_tweaks',
@ -550,6 +553,76 @@ INTERNAL_IPS = (
'::1',
)
# django-rest-framework configuration
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"ietf.api.authentication.ApiKeyAuthentication",
"rest_framework.authentication.SessionAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"ietf.api.permissions.HasApiKey",
],
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
],
"DEFAULT_PARSER_CLASSES": [
"rest_framework.parsers.JSONParser",
],
"DEFAULT_SCHEMA_CLASS": "drf_standardized_errors.openapi.AutoSchema",
"EXCEPTION_HANDLER": "drf_standardized_errors.handler.exception_handler",
}
# DRF OpenApi schema settings
SPECTACULAR_SETTINGS = {
"TITLE": "Datatracker API",
"DESCRIPTION": "Datatracker API",
"VERSION": "1.0.0",
"SCHEMA_PATH_PREFIX": "/api/",
"COMPONENT_SPLIT_REQUEST": True,
"COMPONENT_NO_READ_ONLY_REQUIRED": True,
"SERVERS": [
{"url": "http://localhost:8000", "description": "local dev server"},
{"url": "https://datatracker.ietf.org", "description": "production server"},
],
# The following settings are needed for drf-standardized-errors
"ENUM_NAME_OVERRIDES": {
"ValidationErrorEnum": "drf_standardized_errors.openapi_serializers.ValidationErrorEnum.choices",
"ClientErrorEnum": "drf_standardized_errors.openapi_serializers.ClientErrorEnum.choices",
"ServerErrorEnum": "drf_standardized_errors.openapi_serializers.ServerErrorEnum.choices",
"ErrorCode401Enum": "drf_standardized_errors.openapi_serializers.ErrorCode401Enum.choices",
"ErrorCode403Enum": "drf_standardized_errors.openapi_serializers.ErrorCode403Enum.choices",
"ErrorCode404Enum": "drf_standardized_errors.openapi_serializers.ErrorCode404Enum.choices",
"ErrorCode405Enum": "drf_standardized_errors.openapi_serializers.ErrorCode405Enum.choices",
"ErrorCode406Enum": "drf_standardized_errors.openapi_serializers.ErrorCode406Enum.choices",
"ErrorCode415Enum": "drf_standardized_errors.openapi_serializers.ErrorCode415Enum.choices",
"ErrorCode429Enum": "drf_standardized_errors.openapi_serializers.ErrorCode429Enum.choices",
"ErrorCode500Enum": "drf_standardized_errors.openapi_serializers.ErrorCode500Enum.choices",
},
"POSTPROCESSING_HOOKS": ["drf_standardized_errors.openapi_hooks.postprocess_schema_enums"],
}
# DRF Standardized Errors settings
DRF_STANDARDIZED_ERRORS = {
# enable the standardized errors when DEBUG=True for unhandled exceptions.
# By default, this is set to False so you're able to view the traceback in
# the terminal and get more information about the exception.
"ENABLE_IN_DEBUG_FOR_UNHANDLED_EXCEPTIONS": False,
# ONLY the responses that correspond to these status codes will appear
# in the API schema.
"ALLOWED_ERROR_STATUS_CODES": [
"400",
# "401",
# "403",
"404",
# "405",
# "406",
# "415",
# "429",
# "500",
],
}
# no slash at end
IDTRACKER_BASE_URL = "https://datatracker.ietf.org"
RFCDIFF_BASE_URL = "https://author-tools.ietf.org/iddiff"

View file

@ -24,8 +24,11 @@ django-stubs>=4.2.7,<5 # The django-stubs version used determines the the myp
django-tastypie>=0.14.7,<0.15.0 # Version must be locked in sync with version of Django
django-vite>=2.0.2,<3
django-widget-tweaks>=1.4.12
djangorestframework>=3.15,<4
djlint>=1.0.0 # To auto-indent templates via "djlint --profile django --reformat"
docutils>=0.18.1 # Used only by dbtemplates for RestructuredText
drf-spectacular>=0.27
drf-standardized-errors[openapi] >= 0.14
types-docutils>=0.18.1
factory-boy>=3.3
github3.py>=3.2.0