From b708c8b352eabc5e6e1570b9cb317066f908c1fd Mon Sep 17 00:00:00 2001 From: Sylvain Boissel Date: Wed, 11 Mar 2026 15:34:55 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20add=20a=20is=5Ffirst=5Fcon?= =?UTF-8?q?nection=20flag=20to=20the=20User=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend part of #1796. This changes allows to display an onboarding modal the first time that the get_me() API view is called. I originally tried to check if `User.last_login` was `None`, but it is updated as soon as the user is logged, so I chose to create a flag on the model. --- CHANGELOG.md | 4 ++ src/backend/core/api/serializers.py | 17 +++++- src/backend/core/api/viewsets.py | 20 +++++++ .../0030_user_is_first_connection.py | 32 ++++++++++++ src/backend/core/models.py | 5 ++ .../documents/test_api_document_accesses.py | 21 ++++---- ...test_migrations_0018_update_blank_title.py | 2 - ...d_field_attachments_and_duplicated_from.py | 2 - ...igrations_0030_user_is_first_connection.py | 52 +++++++++++++++++++ src/backend/core/tests/test_api_users.py | 29 +++++++++++ 10 files changed, 169 insertions(+), 15 deletions(-) create mode 100644 src/backend/core/migrations/0030_user_is_first_connection.py create mode 100644 src/backend/core/tests/migrations/test_migrations_0030_user_is_first_connection.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d3d86ed..7d1208be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to ## [Unreleased] +### Added + +- ✨(backend) add a is_first_connection flag to the User model#1938 + ## [v4.7.0] - 2026-03-09 ### Added diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index a7f3d401..a9bdd1b0 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -32,8 +32,21 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = models.User - fields = ["id", "email", "full_name", "short_name", "language"] - read_only_fields = ["id", "email", "full_name", "short_name"] + fields = [ + "id", + "email", + "full_name", + "short_name", + "language", + "is_first_connection", + ] + read_only_fields = [ + "id", + "email", + "full_name", + "short_name", + "is_first_connection", + ] def get_full_name(self, instance): """Return the full name of the user.""" diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 3b36c456..5d9bd360 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -318,6 +318,25 @@ class UserViewSet( self.serializer_class(request.user, context=context).data ) + @drf.decorators.action( + detail=False, + methods=["post"], + url_path="onboarding-done", + permission_classes=[permissions.IsAuthenticated], + ) + def onboarding_done(self, request): + """ + Allows the frontend to mark the first connection as done for the current user, + e.g. after showing an onboarding message. + """ + if request.user.is_first_connection: + request.user.is_first_connection = False + request.user.save(update_fields=["is_first_connection", "updated_at"]) + + return drf.response.Response( + {"detail": "Onboarding marked as done."}, status=status.HTTP_200_OK + ) + class ReconciliationConfirmView(APIView): """API endpoint to confirm user reconciliation emails. @@ -2224,6 +2243,7 @@ class DocumentAccessViewSet( "user__full_name", "user__email", "user__language", + "user__is_first_connection", "document__id", "document__path", "document__depth", diff --git a/src/backend/core/migrations/0030_user_is_first_connection.py b/src/backend/core/migrations/0030_user_is_first_connection.py new file mode 100644 index 00000000..16bb89aa --- /dev/null +++ b/src/backend/core/migrations/0030_user_is_first_connection.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.11 on 2026-03-04 14:49 + +from django.db import migrations, models + + +def set_is_first_connection_false(apps, schema_editor): + """Update all existing user.is_first_connection to False.""" + user = apps.get_model("core", "User") + + user.objects.update(is_first_connection=False) + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0029_userreconciliationcsvimport_userreconciliation"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="is_first_connection", + field=models.BooleanField( + default=True, + help_text="Whether the user has completed the first connection process.", + verbose_name="first connection status", + ), + ), + migrations.RunPython( + set_is_first_connection_false, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 439e3b0c..ac9ae2c6 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -193,6 +193,11 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): "Unselect this instead of deleting accounts." ), ) + is_first_connection = models.BooleanField( + _("first connection status"), + default=True, + help_text=_("Whether the user has completed the first connection process."), + ) objects = UserManager() diff --git a/src/backend/core/tests/documents/test_api_document_accesses.py b/src/backend/core/tests/documents/test_api_document_accesses.py index aa21544c..eed3f3d6 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses.py +++ b/src/backend/core/tests/documents/test_api_document_accesses.py @@ -245,15 +245,18 @@ def test_api_document_accesses_list_authenticated_related_privileged( "path": access.document.path, "depth": access.document.depth, }, - "user": { - "id": str(access.user.id), - "email": access.user.email, - "language": access.user.language, - "full_name": access.user.full_name, - "short_name": access.user.short_name, - } - if access.user - else None, + "user": ( + { + "id": str(access.user.id), + "email": access.user.email, + "language": access.user.language, + "full_name": access.user.full_name, + "short_name": access.user.short_name, + "is_first_connection": access.user.is_first_connection, + } + if access.user + else None + ), "max_ancestors_role": None, "max_role": access.role, "team": access.team, diff --git a/src/backend/core/tests/migrations/test_migrations_0018_update_blank_title.py b/src/backend/core/tests/migrations/test_migrations_0018_update_blank_title.py index 192103f4..c0684c85 100644 --- a/src/backend/core/tests/migrations/test_migrations_0018_update_blank_title.py +++ b/src/backend/core/tests/migrations/test_migrations_0018_update_blank_title.py @@ -1,7 +1,5 @@ import pytest -from core import models - @pytest.mark.django_db def test_update_blank_title_migration(migrator): diff --git a/src/backend/core/tests/migrations/test_migrations_0020_remove_is_public_add_field_attachments_and_duplicated_from.py b/src/backend/core/tests/migrations/test_migrations_0020_remove_is_public_add_field_attachments_and_duplicated_from.py index f94e2a1e..d6fdece4 100644 --- a/src/backend/core/tests/migrations/test_migrations_0020_remove_is_public_add_field_attachments_and_duplicated_from.py +++ b/src/backend/core/tests/migrations/test_migrations_0020_remove_is_public_add_field_attachments_and_duplicated_from.py @@ -7,8 +7,6 @@ from django.core.files.storage import default_storage import pycrdt import pytest -from core import models - @pytest.mark.django_db def test_populate_attachments_on_all_documents(migrator): diff --git a/src/backend/core/tests/migrations/test_migrations_0030_user_is_first_connection.py b/src/backend/core/tests/migrations/test_migrations_0030_user_is_first_connection.py new file mode 100644 index 00000000..4628267a --- /dev/null +++ b/src/backend/core/tests/migrations/test_migrations_0030_user_is_first_connection.py @@ -0,0 +1,52 @@ +"""Module testing migration 0030 about adding is_first_connection to user model.""" + +from django.contrib.auth.hashers import make_password + +import factory +import pytest + +from core import models + + +@pytest.mark.django_db +def test_set_is_first_connection_false(migrator): + """ + Test that once the migration adding is_first_connection column to user model is applied + all existing user have the False value. + """ + old_state = migrator.apply_initial_migration( + ("core", "0029_userreconciliationcsvimport_userreconciliation") + ) + OldUser = old_state.apps.get_model("core", "User") + + old_user1 = OldUser.objects.create( + email="email1@example.com", sub="user1", password=make_password("password") + ) + old_user2 = OldUser.objects.create( + email="email2@example.com", sub="user2", password=make_password("password") + ) + + assert hasattr(old_user1, "is_first_connection") is False + assert hasattr(old_user2, "is_first_connection") is False + + # # Apply the migration + new_state = migrator.apply_tested_migration( + ("core", "0030_user_is_first_connection") + ) + + NewUser = new_state.apps.get_model("core", "User") + + updated_user1 = NewUser.objects.get(id=old_user1.id) + + assert updated_user1.is_first_connection is False + + updated_user2 = NewUser.objects.get(id=old_user2.id) + + assert updated_user2.is_first_connection is False + + # create a new user after migration + + new_user1 = NewUser.objects.create( + email="email3example.com", sub="user3", password=make_password("password") + ) + assert new_user1.is_first_connection is True diff --git a/src/backend/core/tests/test_api_users.py b/src/backend/core/tests/test_api_users.py index 77664ada..165e3680 100644 --- a/src/backend/core/tests/test_api_users.py +++ b/src/backend/core/tests/test_api_users.py @@ -460,6 +460,7 @@ def test_api_users_retrieve_me_authenticated(): "full_name": user.full_name, "language": user.language, "short_name": user.short_name, + "is_first_connection": True, } @@ -489,9 +490,37 @@ def test_api_users_retrieve_me_authenticated_empty_name(): "full_name": "test_foo", "language": user.language, "short_name": "test_foo", + "is_first_connection": True, } +def test_api_users_retrieve_me_onboarding(): + """ + On first connection of a new user, the "is_first_connection" flag should be True. + + The frontend can use this flag to trigger specific behavior for first time users, + e.g. showing an onboarding message, and update the flag to False after onboarding is done. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + # First request: flag should be True + first_response = client.get("/api/v1.0/users/me/") + assert first_response.status_code == 200 + assert first_response.json()["is_first_connection"] is True + + update_response = client.post("/api/v1.0/users/onboarding-done/") + + assert update_response.status_code == 200 + + # Second request: flag should be False + second_response = client.get("/api/v1.0/users/me/") + assert second_response.status_code == 200 + assert second_response.json()["is_first_connection"] is False + + def test_api_users_retrieve_anonymous(): """Anonymous users should not be allowed to retrieve a user.""" client = APIClient()