diff --git a/api/custom_auth/views.py b/api/custom_auth/views.py index 0987c8981338..f4a80619cb26 100644 --- a/api/custom_auth/views.py +++ b/api/custom_auth/views.py @@ -1,3 +1,4 @@ +import json from typing import Any from django.conf import settings @@ -29,6 +30,8 @@ from custom_auth.mfa.trench.utils import user_token_generator from custom_auth.serializers import CustomUserDelete from users.constants import DEFAULT_DELETE_ORPHAN_ORGANISATIONS_VALUE +from users.models import FFAdminUser +from users.serializers import PatchOnboardingSerializer from .models import UserPasswordResetRequest @@ -134,8 +137,29 @@ def perform_destroy(self, instance): # type: ignore[no-untyped-def] ) ) + @action( + detail=False, + methods=["patch"], + url_path="me/onboarding", + permission_classes=[IsAuthenticated], + ) + def patch_onboarding(self, request: Request, *args: Any, **kwargs: Any) -> Response: + user = request.user + assert isinstance(user, FFAdminUser) + serializer = PatchOnboardingSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + existing_onboarding = ( + json.loads(user.onboarding_data) if user.onboarding_data else {} + ) + + updated_onboarding = {**existing_onboarding, **serializer.data} + user.onboarding_data = json.dumps(updated_onboarding) + user.save(update_fields=["onboarding_data"]) + + return Response(status=status.HTTP_204_NO_CONTENT) + @action(["post"], detail=False) - def reset_password(self, request, *args, **kwargs): # type: ignore[no-untyped-def] + def reset_password(self, request: Request, *args: Any, **kwargs: Any) -> Response: serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user = serializer.get_user() diff --git a/api/tests/unit/custom_auth/test_unit_custom_auth_views.py b/api/tests/unit/custom_auth/test_unit_custom_auth_views.py index 09b6d45de632..7f7536f32f95 100644 --- a/api/tests/unit/custom_auth/test_unit_custom_auth_views.py +++ b/api/tests/unit/custom_auth/test_unit_custom_auth_views.py @@ -1,3 +1,7 @@ +import json +from typing import Any + +import pytest from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient @@ -20,3 +24,99 @@ def test_get_current_user(staff_user: FFAdminUser, staff_client: APIClient) -> N assert response_json["first_name"] == staff_user.first_name assert response_json["last_name"] == staff_user.last_name assert response_json["uuid"] == str(staff_user.uuid) + + +def test_get_me_should_return_onboarding_object(db: None) -> None: + # Given + onboarding = { + "tasks": [{"name": "task-1"}], + "tools": {"completed": True, "integrations": ["integration-1"]}, + } + onboarding_serialized = json.dumps(onboarding) + new_user = FFAdminUser.objects.create( + email="testuser@mail.com", + onboarding_data=onboarding_serialized, + ) + + new_user.save() + client = APIClient() + client.force_authenticate(user=new_user) + url = reverse("api-v1:custom_auth:ffadminuser-me") + + # When + response = client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + response_json = response.json() + assert response_json["onboarding"] is not None + assert response_json["onboarding"].get("tools", {}).get("completed") is True + assert response_json["onboarding"].get("tools", {}).get("integrations") == [ + "integration-1" + ] + assert response_json["onboarding"].get("tasks") is not None + assert response_json["onboarding"].get("tasks", [])[0].get("name") == "task-1" + + +@pytest.mark.parametrize( + "data,expected_keys", + [ + ( + {"tasks": [{"name": "task-1", "completed_at": "2024-01-01T12:00:00Z"}]}, + {"tasks"}, + ), + ({"tools": {"completed": True, "integrations": ["integration-1"]}}, {"tools"}), + ( + { + "tasks": [{"name": "task-1", "completed_at": "2024-01-01T12:00:00Z"}], + "tools": {"completed": True, "integrations": ["integration-1"]}, + }, + {"tasks", "tools"}, + ), + ], +) +def test_patch_user_onboarding_updates_only_nested_objects_if_provided( + staff_user: FFAdminUser, + staff_client: APIClient, + data: dict[str, Any], + expected_keys: set[str], +) -> None: + # Given + url = reverse("api-v1:custom_auth:ffadminuser-patch-onboarding") + + # When + response = staff_client.patch(url, data=data, format="json") + + # Then + staff_user.refresh_from_db() + + assert response.status_code == status.HTTP_204_NO_CONTENT + onboarding_json = json.loads(staff_user.onboarding_data or "{}") + assert onboarding_json is not None + if "tasks" in expected_keys: + assert onboarding_json.get("tasks", [])[0] + assert onboarding_json.get("tasks", [])[0].get("name") == data.get("tasks", [])[ + 0 + ].get("name") + if "tools" in expected_keys: + assert onboarding_json.get("tools", {}).get("completed") is True + assert onboarding_json.get("tools", {}).get("integrations") == data.get( + "tools", {} + ).get("integrations") + + +def test_patch_user_onboarding_returns_error_if_tasks_and_tools_are_missing( + staff_user: FFAdminUser, + staff_client: APIClient, +) -> None: + # Given + url = reverse("api-v1:custom_auth:ffadminuser-patch-onboarding") + + # When + response = staff_client.patch(url, data={}, format="json") + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == { + "non_field_errors": ["At least one of 'tasks' or 'tools' must be provided."] + } diff --git a/api/tests/unit/users/test_unit_users_serializers.py b/api/tests/unit/users/test_unit_users_serializers.py index 94b0a34a6eca..6f6255afeff5 100644 --- a/api/tests/unit/users/test_unit_users_serializers.py +++ b/api/tests/unit/users/test_unit_users_serializers.py @@ -1,13 +1,76 @@ +from datetime import datetime + import pytest +from freezegun import freeze_time from rest_framework.exceptions import ValidationError -from users.serializers import UserIdsSerializer +from users.serializers import ( + OnboardingTaskSerializer, + PatchOnboardingSerializer, + UserIdsSerializer, +) -def test_user_ids_serializer_raises_exception_for_invalid_user_id(db): # type: ignore[no-untyped-def] +def test_user_ids_serializer_raises_exception_for_invalid_user_id(db: None) -> None: # Given serializer = UserIdsSerializer(data={"user_ids": [99999]}) # Then with pytest.raises(ValidationError): serializer.is_valid(raise_exception=True) + + +@freeze_time("2025-01-01T12:00:00Z") +def test_onboarding_task_serializer_list_returns_correct_format() -> None: + # Given + data = [ + {"name": "task-1"}, + {"name": "task-2", "completed_at": "2024-01-02T15:00:00Z"}, + {"name": "task-3", "completed_at": None}, + ] + + # When + serializer = OnboardingTaskSerializer(data=data, many=True) + assert serializer.is_valid(), serializer.errors + + # Then + results = serializer.validated_data + assert results[0]["completed_at"] == datetime.now() + assert results[0]["name"] == "task-1" + assert results[1]["completed_at"] == datetime.fromisoformat("2024-01-02T15:00:00Z") + assert results[1]["name"] == "task-2" + assert results[2]["completed_at"] == datetime.now() + assert results[2]["name"] == "task-3" + + +@pytest.mark.parametrize("tools_completed", [True, False, None]) +def test_patch_onboarding_serializer_returns_correct_format( + tools_completed: bool | None, +) -> None: + # Given + data = { + "tasks": [ + {"name": "task-1", "completed_at": "2024-01-02T15:00:00Z"}, + ], + "tools": { + "completed": tools_completed, + "integrations": ["integration-1", "integration-2"], + }, + } + + # When + serializer = PatchOnboardingSerializer(data=data) + assert serializer.is_valid(), serializer.errors + + # Then + data = serializer.validated_data + assert data["tasks"] == [ + { + "name": "task-1", + "completed_at": datetime.fromisoformat("2024-01-02T15:00:00Z"), + }, + ] + assert data["tools"] == { + "completed": True if tools_completed is None else tools_completed, + "integrations": ["integration-1", "integration-2"], + } diff --git a/api/users/migrations/0041_add_onboarding_field.py b/api/users/migrations/0041_add_onboarding_field.py new file mode 100644 index 000000000000..cfe0e54b24b6 --- /dev/null +++ b/api/users/migrations/0041_add_onboarding_field.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.21 on 2025-06-02 12:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0040_default_marketing_consent_given_true"), + ] + + operations = [ + migrations.AddField( + model_name="ffadminuser", + name="onboarding_data", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/api/users/models.py b/api/users/models.py index 9cb3bf7e0db2..f09e5e4be79e 100644 --- a/api/users/models.py +++ b/api/users/models.py @@ -111,7 +111,7 @@ class FFAdminUser(LifecycleModel, AbstractUser): # type: ignore[django-manager- last_name = models.CharField("last name", max_length=150) google_user_id = models.CharField(max_length=50, null=True, blank=True) github_user_id = models.CharField(max_length=50, null=True, blank=True) - + onboarding_data = models.TextField(blank=True, null=True) # Default to True, since it is covered in our Terms of Service. marketing_consent_given = models.BooleanField( default=True, diff --git a/api/users/serializers.py b/api/users/serializers.py index d2d0b834c468..648787239548 100644 --- a/api/users/serializers.py +++ b/api/users/serializers.py @@ -1,3 +1,7 @@ +import json +from datetime import datetime +from typing import Any + from djoser.serializers import ( # type: ignore[import-untyped] UserSerializer as DjoserUserSerializer, ) @@ -148,11 +152,67 @@ class UserPermissionGroupSerializerDetail(UserPermissionGroupSerializer): users = UserPermissionGroupMembershipSerializer(many=True, read_only=True) +class OnboardingToolsSerializer(serializers.Serializer[None]): + completed = serializers.BooleanField(required=False, allow_null=True) + integrations = serializers.ListField( + child=serializers.CharField(), allow_empty=True, required=True + ) + + def validate(self, data: dict[str, Any]) -> dict[str, Any]: + if data.get("completed") is None: + data["completed"] = True + return data + + +class OnboardingTaskSerializer(serializers.Serializer[None]): + name = serializers.CharField() + completed_at = serializers.DateTimeField( + allow_null=True, + required=False, + default=lambda: datetime.now(), + ) + + def validate_completed_at(self, completed_at: datetime | None) -> datetime: + return completed_at or datetime.now() + + +class PatchOnboardingSerializer(serializers.Serializer[None]): + tasks = OnboardingTaskSerializer(many=True, required=False) + tools = OnboardingToolsSerializer(required=False) + + def validate(self, data: dict[str, Any]) -> dict[str, Any]: + if "tasks" not in data and "tools" not in data: + raise serializers.ValidationError( + "At least one of 'tasks' or 'tools' must be provided." + ) + return data + + +class OnboardingResponseTypeSerializer(serializers.Serializer[None]): + tasks = OnboardingTaskSerializer(many=True) + tools = OnboardingToolsSerializer(required=False) + + class CustomCurrentUserSerializer(DjoserUserSerializer): # type: ignore[misc] auth_type = serializers.CharField(read_only=True) is_superuser = serializers.BooleanField(read_only=True) uuid = serializers.UUIDField(read_only=True) + def to_representation(self, instance: FFAdminUser) -> dict[str, Any]: + rep = super().to_representation(instance) + + if instance.onboarding_data is not None: + onboarding_json = json.loads(instance.onboarding_data) + else: + onboarding_json = None + + rep["onboarding"] = ( + OnboardingResponseTypeSerializer(onboarding_json).data + if onboarding_json + else None + ) + return rep # type: ignore[no-any-return] + class Meta(DjoserUserSerializer.Meta): # type: ignore[misc] fields = DjoserUserSerializer.Meta.fields + ( "auth_type",