Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions users/tests/test_apis.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from django.contrib.auth import get_user_model
from django.urls import reverse

from users.factories import UserFactory

User = get_user_model()


Expand Down Expand Up @@ -68,3 +70,51 @@ def test_login_with_non_arbisoft_user(self, mock_google_service, api_client, goo

assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data[0] == "Not arbisoft user."


@pytest.mark.django_db
class TestLoginWithEmailAPI:
""" Test cases for LoginWithEmailView """

@pytest.fixture
def api_client(self):
""" Returns an instance of APIClient """
return APIClient()

@pytest.fixture
def user_password(self):
""" Returns a user password """
return "S3cureP@ssw0rd!"

@pytest.fixture
def user(self, user_password):
""" Returns a user """
user = UserFactory()
user.set_password(user_password)
user.save(update_fields=["password"])
return user

def test_successful_login_with_email_and_password(self, api_client, user, user_password):
""" Test successful login with email and password """
response = api_client.post(
reverse("login_with_email"),
{"email": user.email, "password": user_password},
format="json",
)

assert response.status_code == status.HTTP_200_OK
assert "refresh" in response.data
assert "access" in response.data
assert response.data["user_info"]["first_name"] == user.first_name
assert response.data["user_info"]["last_name"] == user.last_name

def test_login_with_email_invalid_password(self, api_client, user):
""" Test login with email and invalid password """
response = api_client.post(
reverse("login_with_email"),
{"email": user.email, "password": "wrong-password"},
format="json",
)

assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data[0] == "Invalid email or password"
6 changes: 6 additions & 0 deletions users/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@ class LoginUserSerializer(serializers.Serializer):
""" Serializer for the login user """

auth_token = serializers.CharField(required=True)


class EmailLoginSerializer(serializers.Serializer):
""" Serializer for email/password login """
email = serializers.EmailField(required=True)
password = serializers.CharField(required=True, write_only=True)
4 changes: 2 additions & 2 deletions users/v1/urls.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from django.urls import path

from users.v1.views import HelloWorldView, LoginUserView
from users.v1.views import LoginUserView, LoginWithEmailView

urlpatterns = [
path('login', LoginUserView.as_view(), name='login_user'),
path('hello', HelloWorldView.as_view(), name='hello_world'),
path('login/email', LoginWithEmailView.as_view(), name='login_with_email'),
]
99 changes: 88 additions & 11 deletions users/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
from rest_framework_simplejwt.tokens import RefreshToken

from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth import authenticate, get_user_model

from arbisoft_sessions_portal.services.google.google_user_info import GoogleUserInfoService
from users.v1.serializers import LoginUserSerializer
from users.v1.serializers import EmailLoginSerializer, LoginUserSerializer

user_model = get_user_model()

Expand Down Expand Up @@ -111,15 +111,92 @@ def post(self, request):
})


class HelloWorldView(APIView):
""" View for testing the API """
class LoginWithEmailView(APIView):
""" View for logging in the user with email """

permission_classes = []

@extend_schema(
responses={200: {"type": "string", "example": "Hello World"}}
request=EmailLoginSerializer,
responses={
200: {
"type": "object",
"properties": {
"refresh": {
"type": "string",
"description": "JWT refresh token",
"example": "eyJ0eXAiOiJKV1QiLCJhbGc..."
},
"access": {
"type": "string",
"description": "JWT access token",
"example": "eyJ0eXAiOiJKV1QiLCJhbGc..."
},
"user_info": {
"type": "object",
"properties": {
"full_name": {
"type": "string",
"example": "John Doe"
},
"first_name": {
"type": "string",
"example": "John"
},
"last_name": {
"type": "string",
"example": "Doe"
},
"avatar": {
"type": ["string", "null"],
"format": "uri",
"example": None,
"description": "User avatar URL, can be null"
}
}
}
}
},
Comment on lines 119 to +159
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a response serializer (e.g., LoginResponseSerializer) instead of manually constructing the response dictionary and documenting it inline in @extend_schema.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is not a serialized model and involves custom fields and calculations, I believe keeping it as is would be better for maintainability and readability.

400: {
"type": "object",
"properties": {
"detail": {
"type": "string",
"enum": [
"Email and password are required",
"Invalid email or password"
]
}
}
}
},
description="Authenticate user using email and password and return JWT tokens",
)
def get(self, request):
"""
This endpoint returns a simple "Hello World" response.
It can be used to verify that the API is up and running.
"""
return Response("Hello World")
def post(self, request):
""" Log in the user with email """
serializer = EmailLoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True)

email = serializer.validated_data['email']
password = serializer.validated_data['password']

# Django's default authentication backend uses the `username` field, not `email`.
# We first fetch the user by email, then authenticate using their username.
user = user_model.objects.filter(email=email).first()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Since email is unique, prefer get() over filter().first() to make the assumption explicit and fail fast if data integrity is violated.

if user:
user = authenticate(username=user.username, password=password)

if not user:
raise ValidationError("Invalid email or password")

refresh = RefreshToken.for_user(user)
return Response({
'refresh': str(refresh),
'access': str(refresh.access_token),
'user_info': {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Consider including a stable identifier (e.g. user_id) in user_info to simplify client-side user state management.

'full_name': user.get_full_name(),
'first_name': user.first_name,
'last_name': user.last_name,
'avatar': None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: avatar is always returned as None. If no avatar is available, consider omitting the field rather than returning a constant null value.

}
})