Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f381e44
feat: created-patch-onboarding-endpoint-and-added-in-model
Zaimwa9 May 28, 2025
ab26ff3
feat: implemented-patch-tools-in-onboarding
Zaimwa9 May 28, 2025
fe83bdc
feat: default-tools-completed-to-true-but-allows-false
Zaimwa9 May 28, 2025
ad3614b
feat: only-return-completed-in-me-tools
Zaimwa9 May 28, 2025
a7be1fc
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 28, 2025
b825799
feat: finetuned-typing
Zaimwa9 May 28, 2025
492687f
feat: completed-at-api-fallback-to-now
Zaimwa9 May 28, 2025
d655e49
Merge branch 'feat/track-welcome-page-tasks' of github.com:Flagsmith/…
Zaimwa9 May 28, 2025
d46e8d3
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 28, 2025
a460132
feat: added-user-onboarding-serializer-tests
Zaimwa9 May 28, 2025
0d68cd4
Merge branch 'feat/track-welcome-page-tasks' of github.com:Flagsmith/…
Zaimwa9 May 28, 2025
4dce3b3
feat: added-user-onboarding-views-tests
Zaimwa9 May 28, 2025
f668852
feat: moved-endpoint-to-me-onboarding
Zaimwa9 May 28, 2025
ff1496b
feat: added-test-coverage
Zaimwa9 May 28, 2025
bf08568
feat: fixed-non-utc-datetime-test
Zaimwa9 May 28, 2025
9aab712
feat: expose-integrations-in-me
Zaimwa9 May 28, 2025
617171b
feat: updated-tests-with-integrations
Zaimwa9 May 28, 2025
c79a9f5
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 28, 2025
ebb72bc
feat: reviewed-timezone-in-test
Zaimwa9 May 28, 2025
c591b3e
Merge branch 'feat/track-welcome-page-tasks' of github.com:Flagsmith/…
Zaimwa9 May 28, 2025
243ae48
feat: fixed-test
Zaimwa9 May 28, 2025
93c612a
feat: cleaned-up-redundant-to-representation
Zaimwa9 May 29, 2025
88c6eaa
feat: store-onboarding-as-text
Zaimwa9 Jun 2, 2025
3d4be90
feat: adapted-serializer-test
Zaimwa9 Jun 2, 2025
02b46bb
feat: reworked-json-fallbacks
Zaimwa9 Jun 2, 2025
5af6771
feat: renamed-to-onboarding-data
Zaimwa9 Jun 3, 2025
88fb440
feat: improved-typing
Zaimwa9 Jun 4, 2025
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
26 changes: 25 additions & 1 deletion api/custom_auth/views.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from typing import Any

from django.conf import settings
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down
100 changes: 100 additions & 0 deletions api/tests/unit/custom_auth/test_unit_custom_auth_views.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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."]
}
67 changes: 65 additions & 2 deletions api/tests/unit/users/test_unit_users_serializers.py
Original file line number Diff line number Diff line change
@@ -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"],
}
18 changes: 18 additions & 0 deletions api/users/migrations/0041_add_onboarding_field.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
2 changes: 1 addition & 1 deletion api/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
60 changes: 60 additions & 0 deletions api/users/serializers.py
Original file line number Diff line number Diff line change
@@ -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,
)
Expand Down Expand Up @@ -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",
Expand Down
Loading