Browse Source

Start converting emails apis to ninja

David Burke 9 months ago
parent
commit
8554b3583a
6 changed files with 106 additions and 11 deletions
  1. 38 1
      apps/users/api.py
  2. 21 2
      apps/users/schema.py
  3. 9 5
      apps/users/tests/test_api.py
  4. 0 2
      apps/users/views.py
  5. 37 1
      poetry.lock
  6. 1 0
      pyproject.toml

+ 38 - 1
apps/users/api.py

@@ -1,3 +1,5 @@
+from allauth.account.models import EmailAddress
+from asgiref.sync import sync_to_async
 from django.http import Http404, HttpResponse
 from django.shortcuts import aget_object_or_404
 from ninja import Router
@@ -8,7 +10,7 @@ from glitchtip.api.authentication import AuthHttpRequest
 from glitchtip.api.pagination import paginate
 
 from .models import User
-from .schema import UserIn, UserSchema
+from .schema import EmailAddressIn, EmailAddressSchema, UserIn, UserSchema
 
 router = Router()
 
@@ -20,6 +22,10 @@ GET /users/<me_id>/
 DELETE /users/<me_id>/
 PUT /users/<me_id>/
 GET /organizations/burke-software/users/ (Not implemented)
+GET /users/<me_id>/emails/
+POST /users/<me_id>/emails/
+PUT /users/<me_id>/emails/ (Set as primary)
+DELETE /users/<me_id>/emails/
 """
 
 
@@ -30,6 +36,10 @@ def get_user_queryset(user_id: int, add_details=False):
     return qs
 
 
+def get_email_queryset(user_id: int):
+    return EmailAddress.objects.filter(user_id=user_id)
+
+
 @router.get("/users/", response=list[UserSchema], by_alias=True)
 @paginate
 async def list_users(request: AuthHttpRequest, response: HttpResponse):
@@ -82,3 +92,30 @@ async def update_user(request: AuthHttpRequest, user_id: MeID, payload: UserIn):
     await user.asave()
 
     return user
+
+
+@router.get(
+    "/users/{slug:user_id}/emails/", response=list[EmailAddressSchema], by_alias=True
+)
+async def list_emails(request: AuthHttpRequest, user_id: MeID):
+    if user_id != request.auth.user_id and user_id != "me":
+        raise Http404
+    user_id = request.auth.user_id
+    # No pagination, thus sanity check limit
+    return [email async for email in get_email_queryset(user_id=user_id)[:200]]
+
+
+@router.post(
+    "/users/{slug:user_id}/emails/", response={201: EmailAddressSchema}, by_alias=True
+)
+async def create_email(
+    request: AuthHttpRequest, user_id: MeID, payload: EmailAddressIn
+):
+    if user_id != request.auth.user_id and user_id != "me":
+        raise Http404
+    user_id = request.auth.user_id
+    email_address = await EmailAddress.objects.acreate(
+        email=payload.email, user_id=user_id
+    )
+    await sync_to_async(email_address.send_confirmation)(request, signup=False)
+    return 201, email_address

+ 21 - 2
apps/users/schema.py

@@ -1,8 +1,10 @@
 from datetime import datetime
 from typing import Optional
 
+from allauth.account.models import EmailAddress
 from allauth.socialaccount.models import SocialAccount
 from ninja import Field, ModelSchema
+from pydantic import EmailStr
 
 from glitchtip.schema import CamelSchema
 
@@ -10,7 +12,7 @@ from .models import User
 
 
 class SocialAccountSchema(CamelSchema, ModelSchema):
-    email: Optional[str]
+    email: Optional[EmailStr]
     username: Optional[str]
 
     class Meta:
@@ -46,8 +48,9 @@ class UserIn(CamelSchema, ModelSchema):
 
 
 class UserSchema(CamelSchema, ModelSchema):
-    username: str = Field(validation_alias="email")
+    username: EmailStr = Field(validation_alias="email")
     created: datetime = Field(serialization_alias="dateJoined")
+    email: EmailStr
     has_password_auth: bool = Field(validation_alias="has_usable_password")
     identities: list[SocialAccountSchema] = Field(validation_alias="socialaccount_set")
 
@@ -62,3 +65,19 @@ class UserSchema(CamelSchema, ModelSchema):
             "email",
             "options",
         ]
+
+
+class EmailAddressIn(CamelSchema, ModelSchema):
+    email: EmailStr
+
+    class Meta:
+        model = EmailAddress
+        fields = ["email"]
+
+
+class EmailAddressSchema(CamelSchema, ModelSchema):
+    isPrimary: bool = Field(validation_alias="primary")
+    isVerified: bool = Field(validation_alias="verified")
+
+    class Meta(EmailAddressIn.Meta):
+        pass

+ 9 - 5
apps/users/tests/test_api.py

@@ -119,28 +119,32 @@ class UsersTestCase(GlitchTipTestCase):
         res = self.client.get(url)
         self.assertNotContains(res, other_user.email)
 
-    def test_emails_retrieve(self):
+    def test_emails_list(self):
         email_address = baker.make("account.EmailAddress", user=self.user)
         another_user = baker.make("users.user")
         another_email_address = baker.make("account.EmailAddress", user=another_user)
-        url = reverse("user-emails-list", args=["me"])
+        url = reverse("api:list_emails", args=["me"])
         res = self.client.get(url)
         self.assertContains(res, email_address.email)
         self.assertNotContains(res, another_email_address.email)
 
     def test_emails_confirm(self):
         email_address = baker.make("account.EmailAddress", user=self.user)
-        url = reverse("user-emails-list", args=["me"]) + "confirm/"
+        url = reverse("api:list_emails", args=["me"]) + "confirm/"
         data = {"email": email_address.email}
         res = self.client.post(url, data)
         self.assertEqual(res.status_code, 204)
         self.assertEqual(len(mail.outbox), 1)
 
     def test_emails_create(self):
-        url = reverse("user-emails-list", args=["me"])
+        url = reverse("api:list_emails", args=["me"])
+
+        res = self.client.post(url, {"email": "invalid"}, format="json")
+        self.assertEqual(res.status_code, 422)
+
         new_email = "new@exmaple.com"
         data = {"email": new_email}
-        res = self.client.post(url, data)
+        res = self.client.post(url, data, format="json")
         self.assertContains(res, new_email, status_code=201)
         self.assertTrue(
             self.user.emailaddress_set.filter(email=new_email, verified=False).exists()

+ 0 - 2
apps/users/views.py

@@ -103,8 +103,6 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet):
 
 
 class EmailAddressViewSet(
-    mixins.CreateModelMixin,
-    mixins.ListModelMixin,
     viewsets.GenericViewSet,
 ):
     queryset = EmailAddress.objects.all()

+ 37 - 1
poetry.lock

@@ -1273,6 +1273,26 @@ files = [
 [package.dependencies]
 django = ">=3.0"
 
+[[package]]
+name = "dnspython"
+version = "2.6.1"
+description = "DNS toolkit"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "dnspython-2.6.1-py3-none-any.whl", hash = "sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50"},
+    {file = "dnspython-2.6.1.tar.gz", hash = "sha256:e8f0f9c23a7b7cb99ded64e6c3a6f3e701d78f50c55e002b839dea7225cff7cc"},
+]
+
+[package.extras]
+dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "sphinx (>=7.2.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"]
+dnssec = ["cryptography (>=41)"]
+doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"]
+doq = ["aioquic (>=0.9.25)"]
+idna = ["idna (>=3.6)"]
+trio = ["trio (>=0.23)"]
+wmi = ["wmi (>=1.5.1)"]
+
 [[package]]
 name = "drf-nested-routers"
 version = "0.93.5"
@@ -1288,6 +1308,21 @@ files = [
 Django = ">=3.2"
 djangorestframework = ">=3.14.0"
 
+[[package]]
+name = "email-validator"
+version = "2.1.1"
+description = "A robust email address syntax and deliverability validation library."
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "email_validator-2.1.1-py3-none-any.whl", hash = "sha256:97d882d174e2a65732fb43bfce81a3a834cbc1bde8bf419e30ef5ea976370a05"},
+    {file = "email_validator-2.1.1.tar.gz", hash = "sha256:200a70680ba08904be6d1eef729205cc0d687634399a5924d842533efb824b84"},
+]
+
+[package.dependencies]
+dnspython = ">=2.0.0"
+idna = ">=2.0.0"
+
 [[package]]
 name = "exceptiongroup"
 version = "1.2.1"
@@ -3204,6 +3239,7 @@ files = [
 
 [package.dependencies]
 annotated-types = ">=0.4.0"
+email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""}
 pydantic-core = "2.18.4"
 typing-extensions = ">=4.6.1"
 
@@ -4269,4 +4305,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.10"
-content-hash = "b8be6d918a524d60c0b06d9bca17a6af57dd7082ce240e155efaf7a5c3da8ad0"
+content-hash = "ed88f1303c09a153c1bd67153703f28101221ba0b51db8e7406b896e43e29a4d"

+ 1 - 0
pyproject.toml

@@ -45,6 +45,7 @@ boto3 = "1.34.118"
 django-ninja = "^1.0.1"
 orjson = "^3.9.9"
 celery-batches = "^0.9"
+pydantic = {extras = ["email"], version = "^2.7.3"}
 
 [tool.poetry.group.dev.dependencies]
 ipdb = "^0.13.2"