Skip to content

Commit e8b2738

Browse files
feat: track welcome page tasks and integrations (#5500)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 39cb60d commit e8b2738

File tree

6 files changed

+269
-4
lines changed

6 files changed

+269
-4
lines changed

api/custom_auth/views.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
from typing import Any
23

34
from django.conf import settings
@@ -29,6 +30,8 @@
2930
from custom_auth.mfa.trench.utils import user_token_generator
3031
from custom_auth.serializers import CustomUserDelete
3132
from users.constants import DEFAULT_DELETE_ORPHAN_ORGANISATIONS_VALUE
33+
from users.models import FFAdminUser
34+
from users.serializers import PatchOnboardingSerializer
3235

3336
from .models import UserPasswordResetRequest
3437

@@ -134,8 +137,29 @@ def perform_destroy(self, instance): # type: ignore[no-untyped-def]
134137
)
135138
)
136139

140+
@action(
141+
detail=False,
142+
methods=["patch"],
143+
url_path="me/onboarding",
144+
permission_classes=[IsAuthenticated],
145+
)
146+
def patch_onboarding(self, request: Request, *args: Any, **kwargs: Any) -> Response:
147+
user = request.user
148+
assert isinstance(user, FFAdminUser)
149+
serializer = PatchOnboardingSerializer(data=request.data)
150+
serializer.is_valid(raise_exception=True)
151+
existing_onboarding = (
152+
json.loads(user.onboarding_data) if user.onboarding_data else {}
153+
)
154+
155+
updated_onboarding = {**existing_onboarding, **serializer.data}
156+
user.onboarding_data = json.dumps(updated_onboarding)
157+
user.save(update_fields=["onboarding_data"])
158+
159+
return Response(status=status.HTTP_204_NO_CONTENT)
160+
137161
@action(["post"], detail=False)
138-
def reset_password(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
162+
def reset_password(self, request: Request, *args: Any, **kwargs: Any) -> Response:
139163
serializer = self.get_serializer(data=request.data)
140164
serializer.is_valid(raise_exception=True)
141165
user = serializer.get_user()

api/tests/unit/custom_auth/test_unit_custom_auth_views.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import json
2+
from typing import Any
3+
4+
import pytest
15
from django.urls import reverse
26
from rest_framework import status
37
from rest_framework.test import APIClient
@@ -20,3 +24,99 @@ def test_get_current_user(staff_user: FFAdminUser, staff_client: APIClient) -> N
2024
assert response_json["first_name"] == staff_user.first_name
2125
assert response_json["last_name"] == staff_user.last_name
2226
assert response_json["uuid"] == str(staff_user.uuid)
27+
28+
29+
def test_get_me_should_return_onboarding_object(db: None) -> None:
30+
# Given
31+
onboarding = {
32+
"tasks": [{"name": "task-1"}],
33+
"tools": {"completed": True, "integrations": ["integration-1"]},
34+
}
35+
onboarding_serialized = json.dumps(onboarding)
36+
new_user = FFAdminUser.objects.create(
37+
email="testuser@mail.com",
38+
onboarding_data=onboarding_serialized,
39+
)
40+
41+
new_user.save()
42+
client = APIClient()
43+
client.force_authenticate(user=new_user)
44+
url = reverse("api-v1:custom_auth:ffadminuser-me")
45+
46+
# When
47+
response = client.get(url)
48+
49+
# Then
50+
assert response.status_code == status.HTTP_200_OK
51+
response_json = response.json()
52+
assert response_json["onboarding"] is not None
53+
assert response_json["onboarding"].get("tools", {}).get("completed") is True
54+
assert response_json["onboarding"].get("tools", {}).get("integrations") == [
55+
"integration-1"
56+
]
57+
assert response_json["onboarding"].get("tasks") is not None
58+
assert response_json["onboarding"].get("tasks", [])[0].get("name") == "task-1"
59+
60+
61+
@pytest.mark.parametrize(
62+
"data,expected_keys",
63+
[
64+
(
65+
{"tasks": [{"name": "task-1", "completed_at": "2024-01-01T12:00:00Z"}]},
66+
{"tasks"},
67+
),
68+
({"tools": {"completed": True, "integrations": ["integration-1"]}}, {"tools"}),
69+
(
70+
{
71+
"tasks": [{"name": "task-1", "completed_at": "2024-01-01T12:00:00Z"}],
72+
"tools": {"completed": True, "integrations": ["integration-1"]},
73+
},
74+
{"tasks", "tools"},
75+
),
76+
],
77+
)
78+
def test_patch_user_onboarding_updates_only_nested_objects_if_provided(
79+
staff_user: FFAdminUser,
80+
staff_client: APIClient,
81+
data: dict[str, Any],
82+
expected_keys: set[str],
83+
) -> None:
84+
# Given
85+
url = reverse("api-v1:custom_auth:ffadminuser-patch-onboarding")
86+
87+
# When
88+
response = staff_client.patch(url, data=data, format="json")
89+
90+
# Then
91+
staff_user.refresh_from_db()
92+
93+
assert response.status_code == status.HTTP_204_NO_CONTENT
94+
onboarding_json = json.loads(staff_user.onboarding_data or "{}")
95+
assert onboarding_json is not None
96+
if "tasks" in expected_keys:
97+
assert onboarding_json.get("tasks", [])[0]
98+
assert onboarding_json.get("tasks", [])[0].get("name") == data.get("tasks", [])[
99+
0
100+
].get("name")
101+
if "tools" in expected_keys:
102+
assert onboarding_json.get("tools", {}).get("completed") is True
103+
assert onboarding_json.get("tools", {}).get("integrations") == data.get(
104+
"tools", {}
105+
).get("integrations")
106+
107+
108+
def test_patch_user_onboarding_returns_error_if_tasks_and_tools_are_missing(
109+
staff_user: FFAdminUser,
110+
staff_client: APIClient,
111+
) -> None:
112+
# Given
113+
url = reverse("api-v1:custom_auth:ffadminuser-patch-onboarding")
114+
115+
# When
116+
response = staff_client.patch(url, data={}, format="json")
117+
118+
# Then
119+
assert response.status_code == status.HTTP_400_BAD_REQUEST
120+
assert response.json() == {
121+
"non_field_errors": ["At least one of 'tasks' or 'tools' must be provided."]
122+
}
Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,76 @@
1+
from datetime import datetime
2+
13
import pytest
4+
from freezegun import freeze_time
25
from rest_framework.exceptions import ValidationError
36

4-
from users.serializers import UserIdsSerializer
7+
from users.serializers import (
8+
OnboardingTaskSerializer,
9+
PatchOnboardingSerializer,
10+
UserIdsSerializer,
11+
)
512

613

7-
def test_user_ids_serializer_raises_exception_for_invalid_user_id(db): # type: ignore[no-untyped-def]
14+
def test_user_ids_serializer_raises_exception_for_invalid_user_id(db: None) -> None:
815
# Given
916
serializer = UserIdsSerializer(data={"user_ids": [99999]})
1017

1118
# Then
1219
with pytest.raises(ValidationError):
1320
serializer.is_valid(raise_exception=True)
21+
22+
23+
@freeze_time("2025-01-01T12:00:00Z")
24+
def test_onboarding_task_serializer_list_returns_correct_format() -> None:
25+
# Given
26+
data = [
27+
{"name": "task-1"},
28+
{"name": "task-2", "completed_at": "2024-01-02T15:00:00Z"},
29+
{"name": "task-3", "completed_at": None},
30+
]
31+
32+
# When
33+
serializer = OnboardingTaskSerializer(data=data, many=True)
34+
assert serializer.is_valid(), serializer.errors
35+
36+
# Then
37+
results = serializer.validated_data
38+
assert results[0]["completed_at"] == datetime.now()
39+
assert results[0]["name"] == "task-1"
40+
assert results[1]["completed_at"] == datetime.fromisoformat("2024-01-02T15:00:00Z")
41+
assert results[1]["name"] == "task-2"
42+
assert results[2]["completed_at"] == datetime.now()
43+
assert results[2]["name"] == "task-3"
44+
45+
46+
@pytest.mark.parametrize("tools_completed", [True, False, None])
47+
def test_patch_onboarding_serializer_returns_correct_format(
48+
tools_completed: bool | None,
49+
) -> None:
50+
# Given
51+
data = {
52+
"tasks": [
53+
{"name": "task-1", "completed_at": "2024-01-02T15:00:00Z"},
54+
],
55+
"tools": {
56+
"completed": tools_completed,
57+
"integrations": ["integration-1", "integration-2"],
58+
},
59+
}
60+
61+
# When
62+
serializer = PatchOnboardingSerializer(data=data)
63+
assert serializer.is_valid(), serializer.errors
64+
65+
# Then
66+
data = serializer.validated_data
67+
assert data["tasks"] == [
68+
{
69+
"name": "task-1",
70+
"completed_at": datetime.fromisoformat("2024-01-02T15:00:00Z"),
71+
},
72+
]
73+
assert data["tools"] == {
74+
"completed": True if tools_completed is None else tools_completed,
75+
"integrations": ["integration-1", "integration-2"],
76+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 4.2.21 on 2025-06-02 12:19
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("users", "0040_default_marketing_consent_given_true"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="ffadminuser",
15+
name="onboarding_data",
16+
field=models.TextField(blank=True, null=True),
17+
),
18+
]

api/users/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ class FFAdminUser(LifecycleModel, AbstractUser): # type: ignore[django-manager-
111111
last_name = models.CharField("last name", max_length=150)
112112
google_user_id = models.CharField(max_length=50, null=True, blank=True)
113113
github_user_id = models.CharField(max_length=50, null=True, blank=True)
114-
114+
onboarding_data = models.TextField(blank=True, null=True)
115115
# Default to True, since it is covered in our Terms of Service.
116116
marketing_consent_given = models.BooleanField(
117117
default=True,

api/users/serializers.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import json
2+
from datetime import datetime
3+
from typing import Any
4+
15
from djoser.serializers import ( # type: ignore[import-untyped]
26
UserSerializer as DjoserUserSerializer,
37
)
@@ -148,11 +152,67 @@ class UserPermissionGroupSerializerDetail(UserPermissionGroupSerializer):
148152
users = UserPermissionGroupMembershipSerializer(many=True, read_only=True)
149153

150154

155+
class OnboardingToolsSerializer(serializers.Serializer[None]):
156+
completed = serializers.BooleanField(required=False, allow_null=True)
157+
integrations = serializers.ListField(
158+
child=serializers.CharField(), allow_empty=True, required=True
159+
)
160+
161+
def validate(self, data: dict[str, Any]) -> dict[str, Any]:
162+
if data.get("completed") is None:
163+
data["completed"] = True
164+
return data
165+
166+
167+
class OnboardingTaskSerializer(serializers.Serializer[None]):
168+
name = serializers.CharField()
169+
completed_at = serializers.DateTimeField(
170+
allow_null=True,
171+
required=False,
172+
default=lambda: datetime.now(),
173+
)
174+
175+
def validate_completed_at(self, completed_at: datetime | None) -> datetime:
176+
return completed_at or datetime.now()
177+
178+
179+
class PatchOnboardingSerializer(serializers.Serializer[None]):
180+
tasks = OnboardingTaskSerializer(many=True, required=False)
181+
tools = OnboardingToolsSerializer(required=False)
182+
183+
def validate(self, data: dict[str, Any]) -> dict[str, Any]:
184+
if "tasks" not in data and "tools" not in data:
185+
raise serializers.ValidationError(
186+
"At least one of 'tasks' or 'tools' must be provided."
187+
)
188+
return data
189+
190+
191+
class OnboardingResponseTypeSerializer(serializers.Serializer[None]):
192+
tasks = OnboardingTaskSerializer(many=True)
193+
tools = OnboardingToolsSerializer(required=False)
194+
195+
151196
class CustomCurrentUserSerializer(DjoserUserSerializer): # type: ignore[misc]
152197
auth_type = serializers.CharField(read_only=True)
153198
is_superuser = serializers.BooleanField(read_only=True)
154199
uuid = serializers.UUIDField(read_only=True)
155200

201+
def to_representation(self, instance: FFAdminUser) -> dict[str, Any]:
202+
rep = super().to_representation(instance)
203+
204+
if instance.onboarding_data is not None:
205+
onboarding_json = json.loads(instance.onboarding_data)
206+
else:
207+
onboarding_json = None
208+
209+
rep["onboarding"] = (
210+
OnboardingResponseTypeSerializer(onboarding_json).data
211+
if onboarding_json
212+
else None
213+
)
214+
return rep # type: ignore[no-any-return]
215+
156216
class Meta(DjoserUserSerializer.Meta): # type: ignore[misc]
157217
fields = DjoserUserSerializer.Meta.fields + (
158218
"auth_type",

0 commit comments

Comments
 (0)