diff --git a/CHANGELOG.md b/CHANGELOG.md index 79676723..45b045ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to - ✨(backend) manage reconciliation requests for user accounts #1878 - 👷(CI) add GHCR workflow for forked repo testing #1851 - ✨(backend) allow the duplication of subpages #1893 +- ✨(backend) Onboarding docs for new users #1891 ### Changed diff --git a/docs/env.md b/docs/env.md index 2f153cee..f8701566 100644 --- a/docs/env.md +++ b/docs/env.md @@ -7,7 +7,7 @@ Here we describe all environment variables that can be set for the docs applicat These are the environment variables you can set for the `impress-backend` container. | Option | Description | default | -|-------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------| +| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated | | AI_API_KEY | AI key to be used for AI Base url | | | AI_BASE_URL | OpenAI compatible AI base url | | @@ -17,7 +17,7 @@ These are the environment variables you can set for the `impress-backend` contai | API_USERS_LIST_LIMIT | Limit on API users | 5 | | API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute | | API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour | -| API_USERS_SEARCH_QUERY_MIN_LENGTH | Minimum characters to insert to search a user | 3 | +| API_USERS_SEARCH_QUERY_MIN_LENGTH | Minimum characters to insert to search a user | 3 | | AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | | | AWS_S3_ENDPOINT_URL | S3 endpoint | | | AWS_S3_REGION_NAME | Region name for s3 endpoint | | @@ -35,7 +35,7 @@ These are the environment variables you can set for the `impress-backend` contai | CONVERSION_API_SECURE | Require secure conversion api | false | | CONVERSION_API_TIMEOUT | Conversion api timeout | 30 | | CONVERSION_FILE_MAX_SIZE | The file max size allowed when uploaded to convert it | 20971520 (20MB) | -| CONVERSION_FILE_EXTENSIONS_ALLOWED | Extension list managed by the conversion service | [".docx", ".md"] +| CONVERSION_FILE_EXTENSIONS_ALLOWED | Extension list managed by the conversion service | [".docx", ".md"] | | CRISP_WEBSITE_ID | Crisp website id for support | | | DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 | | DB_HOST | Host of the database | localhost | @@ -58,22 +58,22 @@ These are the environment variables you can set for the `impress-backend` contai | DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | | | DJANGO_EMAIL_LOGO_IMG | Logo for the email | | | DJANGO_EMAIL_PORT | Port used to connect to email host | | -| DJANGO_EMAIL_URL_APP | Url used in the email to go to the app | | +| DJANGO_EMAIL_URL_APP | Url used in the email to go to the app | | | DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false | | DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false | | DJANGO_SECRET_KEY | Secret key | | | DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] | -| DOCSPEC_API_URL | URL to endpoint of DocSpec conversion API | | +| DOCSPEC_API_URL | URL to endpoint of DocSpec conversion API | | | DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 | | FRONTEND_CSS_URL | To add a external css file to the app | | -| FRONTEND_JS_URL | To add a external js file to the app | | +| FRONTEND_JS_URL | To add a external js file to the app | | | FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false | | FRONTEND_THEME | Frontend theme to use | | | LANGUAGE_CODE | Default language | en-us | | LANGFUSE_SECRET_KEY | The Langfuse secret key used by the sdk | None | | LANGFUSE_PUBLIC_KEY | The Langfuse public key used by the sdk | None | | LANGFUSE_BASE_URL | The Langfuse base url used by the sdk | None | -| LASUITE_MARKETING_BACKEND | Backend used when SIGNUP_NEW_USER_TO_MARKETING_EMAIL is True. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | lasuite.marketing.backends.dummy.DummyBackend | +| LASUITE_MARKETING_BACKEND | Backend used when SIGNUP_NEW_USER_TO_MARKETING_EMAIL is True. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | lasuite.marketing.backends.dummy.DummyBackend | | LASUITE_MARKETING_PARAMETERS | The parameters to configure LASUITE_MARKETING_BACKEND. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | {} | | LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | | LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | @@ -120,11 +120,12 @@ These are the environment variables you can set for the `impress-backend` contai | THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json | | TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 | | USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] | +| USER_ONBOARDING_DOCUMENTS | A list of documents IDs for which a read-only access will be created for new s | [] | +| USER_ONBOARDING_SANDBOX_DOCUMENT | ID of a template sandbox document that will be duplicated for new users | | | USER_RECONCILIATION_FORM_URL | URL of a third-party form for user reconciliation requests | | | Y_PROVIDER_API_BASE_URL | Y Provider url | | | Y_PROVIDER_API_KEY | Y provider API key | | - ## impress-frontend image These are the environment variables you can set to build the `impress-frontend` image. @@ -135,29 +136,30 @@ If you want to build the Docker image, this variable is used as an argument in t Example: -``` +```bash docker build -f src/frontend/Dockerfile --target frontend-production --build-arg PUBLISH_AS_MIT=false docs-frontend:latest -``` +``` If you want to build the front-end application using the yarn build command, you can edit the file `src/frontend/apps/impress/.env` with the `NODE_ENV=production` environment variable and modify it. Alternatively, you can use the listed environment variables with the prefix `NEXT_PUBLIC_` (for example, `NEXT_PUBLIC_PUBLISH_AS_MIT=false`). Example: -``` +```bash cd src/frontend/apps/impress NODE_ENV=production NEXT_PUBLIC_PUBLISH_AS_MIT=false yarn build ``` -| Option | Description | default | -| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| API_ORIGIN | backend domain - it uses the current domain if not initialized | | -| SW_DEACTIVATED | To not install the service worker | | -| PUBLISH_AS_MIT | Removes packages whose licences are incompatible with the MIT licence (see below) | true | +| Option | Description | default | +| -------------- | ---------------------------------------------------------------------------------- | ------- | +| API_ORIGIN | backend domain - it uses the current domain if not initialized | | +| SW_DEACTIVATED | To not install the service worker | | +| PUBLISH_AS_MIT | Removes packages whose licences are incompatible with the MIT licence (see below) | true | Packages with licences incompatible with the MIT licence: -* `xl-docx-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE), -* `xl-pdf-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE), -* `xl-multi-column`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-multi-column/LICENSE). + +* `xl-docx-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE), +* `xl-pdf-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE), +* `xl-multi-column`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-multi-column/LICENSE). In `.env.development`, `PUBLISH_AS_MIT` is set to `false`, allowing developers to test Docs with all its features. diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 5aaaa4a9..9f29b6b9 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -209,14 +209,76 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): def save(self, *args, **kwargs): """ - If it's a new user, give its user access to the documents to which s.he was invited. + If it's a new user, give its user access to the documents they were invited to. """ is_adding = self._state.adding super().save(*args, **kwargs) if is_adding: + self._handle_onboarding_documents_access() + self._duplicate_onboarding_sandbox_document() self._convert_valid_invitations() + def _handle_onboarding_documents_access(self): + """ + If the user is new and there are documents configured to be given to new users, + give access to these documents and pin them as favorites for the user. + """ + if settings.USER_ONBOARDING_DOCUMENTS: + onboarding_document_ids = set(settings.USER_ONBOARDING_DOCUMENTS) + onboarding_accesses = [] + favorite_documents = [] + for document_id in onboarding_document_ids: + try: + document = Document.objects.get(id=document_id) + except Document.DoesNotExist: + logger.warning( + "Onboarding document with id %s does not exist. Skipping.", + document_id, + ) + continue + + onboarding_accesses.append( + DocumentAccess( + user=self, document=document, role=RoleChoices.READER + ) + ) + favorite_documents.append( + DocumentFavorite(user=self, document_id=document_id) + ) + + DocumentAccess.objects.bulk_create(onboarding_accesses) + DocumentFavorite.objects.bulk_create(favorite_documents) + + def _duplicate_onboarding_sandbox_document(self): + """ + If the user is new and there is a sandbox document configured, + duplicate the sandbox document for the user + """ + if settings.USER_ONBOARDING_SANDBOX_DOCUMENT: + sandbox_id = settings.USER_ONBOARDING_SANDBOX_DOCUMENT + try: + template_document = Document.objects.get(id=sandbox_id) + + sandbox_document = template_document.add_sibling( + "right", + title=template_document.title, + content=template_document.content, + attachments=template_document.attachments, + duplicated_from=template_document, + creator=self, + ) + + DocumentAccess.objects.create( + user=self, document=sandbox_document, role=RoleChoices.OWNER + ) + + except Document.DoesNotExist: + logger.warning( + "Onboarding sandbox document with id %s does not exist. Skipping.", + sandbox_id, + ) + def _convert_valid_invitations(self): """ Convert valid invitations to document accesses. diff --git a/src/backend/core/tests/test_models_users.py b/src/backend/core/tests/test_models_users.py index 8c0b0c74..33cac6bd 100644 --- a/src/backend/core/tests/test_models_users.py +++ b/src/backend/core/tests/test_models_users.py @@ -2,7 +2,11 @@ Unit tests for the User model """ +import uuid +from unittest.mock import patch + from django.core.exceptions import ValidationError +from django.test.utils import override_settings import pytest @@ -74,3 +78,217 @@ def test_modes_users_convert_valid_invitations(): id=invitation_other_document.id ).exists() assert models.Invitation.objects.filter(id=other_email_invitation.id).exists() + + +@override_settings(USER_ONBOARDING_DOCUMENTS=[]) +def test_models_users_handle_onboarding_documents_access_empty_setting(): + """ + When USER_ONBOARDING_DOCUMENTS is empty, no accesses should be created. + """ + user = factories.UserFactory() + assert models.DocumentAccess.objects.filter(user=user).count() == 0 + + +def test_models_users_handle_onboarding_documents_access_with_single_document(): + """ + When USER_ONBOARDING_DOCUMENTS has a valid document ID, + an access should be created for the new user with the READER role. + + The document should be pinned as a favorite for the user. + """ + document = factories.DocumentFactory() + + with override_settings(USER_ONBOARDING_DOCUMENTS=[str(document.id)]): + user = factories.UserFactory() + + assert ( + models.DocumentAccess.objects.filter(user=user, document=document).count() == 1 + ) + + access = models.DocumentAccess.objects.get(user=user, document=document) + assert access.role == models.RoleChoices.READER + + user_favorites = models.DocumentFavorite.objects.filter(user=user) + assert user_favorites.count() == 1 + assert user_favorites.filter(document=document).exists() + + +def test_models_users_handle_onboarding_documents_access_with_multiple_documents(): + """ + When USER_ONBOARDING_DOCUMENTS has multiple valid document IDs, + accesses should be created for all documents. + + All accesses should have the READER role. + All documents should be pinned as favorites for the user. + """ + document1 = factories.DocumentFactory(title="Document 1") + document2 = factories.DocumentFactory(title="Document 2") + document3 = factories.DocumentFactory(title="Document 3") + + with override_settings( + USER_ONBOARDING_DOCUMENTS=[ + str(document1.id), + str(document2.id), + str(document3.id), + ] + ): + user = factories.UserFactory() + + user_accesses = models.DocumentAccess.objects.filter(user=user) + assert user_accesses.count() == 3 + + assert models.DocumentAccess.objects.filter(user=user, document=document1).exists() + assert models.DocumentAccess.objects.filter(user=user, document=document2).exists() + assert models.DocumentAccess.objects.filter(user=user, document=document3).exists() + + for access in user_accesses: + assert access.role == models.RoleChoices.READER + + user_favorites = models.DocumentFavorite.objects.filter(user=user) + assert user_favorites.count() == 3 + assert user_favorites.filter(document=document1).exists() + assert user_favorites.filter(document=document2).exists() + assert user_favorites.filter(document=document3).exists() + + +def test_models_users_handle_onboarding_documents_access_with_invalid_document_id(): + """ + When USER_ONBOARDING_DOCUMENTS has an invalid document ID, + it should be skipped and logged, but not raise an exception. + """ + invalid_id = uuid.uuid4() + + with override_settings(USER_ONBOARDING_DOCUMENTS=[str(invalid_id)]): + with patch("core.models.logger") as mock_logger: + user = factories.UserFactory() + + mock_logger.warning.assert_called_once() + call_args = mock_logger.warning.call_args + assert "Onboarding document with id" in call_args[0][0] + + assert models.DocumentAccess.objects.filter(user=user).count() == 0 + + +def test_models_users_handle_onboarding_documents_access_duplicate_prevention(): + """ + If the same document is listed multiple times in USER_ONBOARDING_DOCUMENTS, + it should only create one access (or handle duplicates gracefully). + """ + document = factories.DocumentFactory() + + with override_settings( + USER_ONBOARDING_DOCUMENTS=[str(document.id), str(document.id)] + ): + user = factories.UserFactory() + + user_accesses = models.DocumentAccess.objects.filter(user=user, document=document) + + assert user_accesses.count() >= 1 + + +@override_settings(USER_ONBOARDING_SANDBOX_DOCUMENT=None) +def test_models_users_duplicate_onboarding_sandbox_document_no_setting(): + """ + When USER_ONBOARDING_SANDBOX_DOCUMENT is not set, no sandbox document should be created. + """ + user = factories.UserFactory() + + assert ( + models.Document.objects.filter(creator=user, title__icontains="Sandbox").count() + == 0 + ) + + initial_accesses = models.DocumentAccess.objects.filter(user=user).count() + assert initial_accesses == 0 + + +def test_models_users_duplicate_onboarding_sandbox_document_creates_sandbox(): + """ + When USER_ONBOARDING_SANDBOX_DOCUMENT is set with a valid template document, + a new sandbox document should be created for the user with OWNER access. + """ + template_document = factories.DocumentFactory(title="Getting started with Docs") + + with override_settings(USER_ONBOARDING_SANDBOX_DOCUMENT=str(template_document.id)): + user = factories.UserFactory() + + sandbox_docs = models.Document.objects.filter( + creator=user, title="Getting started with Docs" + ) + assert sandbox_docs.count() == 1 + + sandbox_doc = sandbox_docs.first() + assert sandbox_doc.creator == user + assert sandbox_doc.duplicated_from == template_document + + access = models.DocumentAccess.objects.get(user=user, document=sandbox_doc) + assert access.role == models.RoleChoices.OWNER + + +def test_models_users_duplicate_onboarding_sandbox_document_with_invalid_template_id(): + """ + When USER_ONBOARDING_SANDBOX_DOCUMENT has an invalid document ID, + it should be skipped and logged, but not raise an exception. + """ + invalid_id = uuid.uuid4() + + with override_settings(USER_ONBOARDING_SANDBOX_DOCUMENT=str(invalid_id)): + with patch("core.models.logger") as mock_logger: + user = factories.UserFactory() + + mock_logger.warning.assert_called_once() + call_args = mock_logger.warning.call_args + assert "Onboarding sandbox document with id" in call_args[0][0] + + sandbox_docs = models.Document.objects.filter(creator=user) + assert sandbox_docs.count() == 0 + + +def test_models_users_duplicate_onboarding_sandbox_document_creates_unique_sandbox_per_user(): + """ + Each new user should get their own independent sandbox document. + """ + template_document = factories.DocumentFactory(title="Getting started with Docs") + + with override_settings(USER_ONBOARDING_SANDBOX_DOCUMENT=str(template_document.id)): + user1 = factories.UserFactory() + user2 = factories.UserFactory() + + sandbox_docs_user1 = models.Document.objects.filter( + creator=user1, title="Getting started with Docs" + ) + sandbox_docs_user2 = models.Document.objects.filter( + creator=user2, title="Getting started with Docs" + ) + + assert sandbox_docs_user1.count() == 1 + assert sandbox_docs_user2.count() == 1 + + assert sandbox_docs_user1.first().id != sandbox_docs_user2.first().id + + +def test_models_users_duplicate_onboarding_sandbox_document_integration_with_other_methods(): + """ + Verify that sandbox creation works alongside other onboarding methods. + """ + template_document = factories.DocumentFactory(title="Getting started with Docs") + onboarding_doc = factories.DocumentFactory(title="Onboarding Document") + + with override_settings( + USER_ONBOARDING_SANDBOX_DOCUMENT=str(template_document.id), + USER_ONBOARDING_DOCUMENTS=[str(onboarding_doc.id)], + ): + user = factories.UserFactory() + + sandbox_doc = models.Document.objects.filter( + creator=user, title="Getting started with Docs" + ).first() + + user_accesses = models.DocumentAccess.objects.filter(user=user) + assert user_accesses.count() == 2 + + sandbox_access = user_accesses.get(document=sandbox_doc) + onboarding_access = user_accesses.get(document=onboarding_doc) + + assert sandbox_access.role == models.RoleChoices.OWNER + assert onboarding_access.role == models.RoleChoices.READER diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 5142ab0c..b0088809 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -884,7 +884,12 @@ class Base(Configuration): USER_RECONCILIATION_FORM_URL = values.Value( None, environ_name="USER_RECONCILIATION_FORM_URL", environ_prefix=None ) - + USER_ONBOARDING_DOCUMENTS = values.ListValue( + [], environ_name="USER_ONBOARDING_DOCUMENTS", environ_prefix=None + ) + USER_ONBOARDING_SANDBOX_DOCUMENT = values.Value( + None, environ_name="USER_ONBOARDING_SANDBOX_DOCUMENT", environ_prefix=None + ) # Marketing and communication settings SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue( False,