diff --git a/CHANGELOG.md b/CHANGELOG.md
index 289e1a9d1..ebf80bfc0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,9 +15,11 @@ and this project adheres to
- 📝(project) add troubleshoot doc #1066
- 📝(project) add system-requirement doc #1066
- 🔧(front) configure x-frame-options to DENY in nginx conf #1084
-- (doc) add documentation to install with compose #855
- ✨(backend) allow to disable checking unsafe mimetype on attachment upload
- ✨Ask for access #1081
+- ✨(doc) add documentation to install with compose #855
+- ✨ Give priority to users connected to collaboration server
+ (aka no websocket feature) #1093
### Changed
diff --git a/docs/env.md b/docs/env.md
index 0f1a092f8..5f239ff2c 100644
--- a/docs/env.md
+++ b/docs/env.md
@@ -6,104 +6,104 @@ 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 | |
-| AI_FEATURE_ENABLED | Enable AI options | false |
-| AI_MODEL | AI Model to use | |
-| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
-| 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 |
-| 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 | |
-| AWS_S3_SECRET_ACCESS_KEY | access key for s3 endpoint | |
-| AWS_STORAGE_BUCKET_NAME | bucket name for s3 endpoint | impress-media-storage |
-| CACHES_DEFAULT_TIMEOUT | cache default timeout | 30 |
-| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
-| COLLABORATION_API_URL | collaboration api host | |
-| COLLABORATION_SERVER_SECRET | collaboration api secret | |
-| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
-| COLLABORATION_WS_URL | collaboration websocket url | |
-| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
-| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert |
-| CONVERSION_API_SECURE | Require secure conversion api | false |
-| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
-| CONTENT_SECURITY_POLICY_DIRECTIVES | A dict of directives set in the Content-Security-Policy header | All directives are set to 'none' |
-| CONTENT_SECURITY_POLICY_EXCLUDE_URL_PREFIXES | Url with this prefix will not have the header Content-Security-Policy included | |
-| 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 |
-| DB_NAME | name of the database | impress |
-| DB_PASSWORD | password to authenticate with | pass |
-| DB_PORT | port of the database | 5432 |
-| DB_USER | user to authenticate with | dinum |
-| DJANGO_ALLOWED_HOSTS | allowed hosts | [] |
-| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | celery broker transport options | {} |
-| DJANGO_CELERY_BROKER_URL | celery broker url | redis://redis:6379/0 |
-| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all CORS origins | false |
-| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | list of origins allowed for CORS using regulair expressions | [] |
-| DJANGO_CORS_ALLOWED_ORIGINS | list of origins allowed for CORS | [] |
-| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
-| DJANGO_EMAIL_BACKEND | email backend library | django.core.mail.backends.smtp.EmailBackend |
-| DJANGO_EMAIL_BRAND_NAME | brand name for email | |
-| DJANGO_EMAIL_FROM | email address used as sender | from@example.com |
-| DJANGO_EMAIL_HOST | host name of email | |
-| DJANGO_EMAIL_HOST_PASSWORD | password to authenticate with on the email host | |
-| 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_USE_SSL | use sstl 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 | | [] |
-| DOCUMENT_IMAGE_MAX_SIZE | maximum size of document in bytes | 10485760 |
-| FRONTEND_CSS_URL | To add a external css 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 |
-| 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 |
-| LOGIN_REDIRECT_URL | login redirect url | |
-| LOGIN_REDIRECT_URL_FAILURE | login redirect url on failure | |
-| LOGOUT_REDIRECT_URL | logout redirect url | |
-| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
-| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
-| MEDIA_BASE_URL | | |
-| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
-| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
-| OIDC_CREATE_USER | create used on OIDC | false |
-| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | faillback to email for identification | true |
-| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
-| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
-| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
-| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
-| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
-| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
-| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
-| OIDC_RP_CLIENT_ID | client id used for OIDC | impress |
-| OIDC_RP_CLIENT_SECRET | client secret used for OIDC | |
-| OIDC_RP_SCOPES | scopes requested for OIDC | openid email |
-| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
-| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
-| OIDC_USE_NONCE | use nonce for OIDC | true |
-| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
-| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
-| POSTHOG_KEY | posthog key for analytics | |
-| REDIS_URL | cache url | redis://redis:6379/1 |
-| SENTRY_DSN | sentry host | |
-| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
-| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
-| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
-| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
-| 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 | [] |
-| Y_PROVIDER_API_BASE_URL | Y Provider url | |
-| Y_PROVIDER_API_KEY | Y provider API key | |
+| 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 | |
+| AI_FEATURE_ENABLED | Enable AI options | false |
+| AI_MODEL | AI Model to use | |
+| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
+| 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 |
+| 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 | |
+| AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | |
+| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
+| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
+| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
+| COLLABORATION_API_URL | Collaboration api host | |
+| COLLABORATION_SERVER_SECRET | Collaboration api secret | |
+| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
+| COLLABORATION_WS_URL | Collaboration websocket url | |
+| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
+| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert |
+| CONVERSION_API_SECURE | Require secure conversion api | false |
+| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
+| 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 |
+| DB_NAME | Name of the database | impress |
+| DB_PASSWORD | Password to authenticate with | pass |
+| DB_PORT | Port of the database | 5432 |
+| DB_USER | User to authenticate with | dinum |
+| DJANGO_ALLOWED_HOSTS | Allowed hosts | [] |
+| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} |
+| DJANGO_CELERY_BROKER_URL | Celery broker url | redis://redis:6379/0 |
+| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
+| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | List of origins allowed for CORS using regulair expressions | [] |
+| DJANGO_CORS_ALLOWED_ORIGINS | List of origins allowed for CORS | [] |
+| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
+| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
+| DJANGO_EMAIL_BRAND_NAME | Brand name for email | |
+| DJANGO_EMAIL_FROM | Email address used as sender | from@example.com |
+| DJANGO_EMAIL_HOST | Hostname of email | |
+| DJANGO_EMAIL_HOST_PASSWORD | Password to authenticate with on the email host | |
+| 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_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 | | [] |
+| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
+| FRONTEND_CSS_URL | To add a external css 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 |
+| 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 |
+| LOGIN_REDIRECT_URL | Login redirect url | |
+| LOGIN_REDIRECT_URL_FAILURE | Login redirect url on failure | |
+| LOGOUT_REDIRECT_URL | Logout redirect url | |
+| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
+| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
+| MEDIA_BASE_URL | | |
+| NO_WEBSOCKET_CACHE_TIMEOUT | Cache used to store current editor session key when only users without websocket are editing a document | 120 |
+| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
+| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
+| OIDC_CREATE_USER | Create used on OIDC | false |
+| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | true |
+| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
+| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
+| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
+| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
+| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
+| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
+| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
+| OIDC_RP_CLIENT_ID | Client id used for OIDC | impress |
+| OIDC_RP_CLIENT_SECRET | Client secret used for OIDC | |
+| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email |
+| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
+| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
+| OIDC_USE_NONCE | Use nonce for OIDC | true |
+| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
+| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
+| POSTHOG_KEY | Posthog key for analytics | |
+| REDIS_URL | Cache url | redis://redis:6379/1 |
+| SENTRY_DSN | Sentry host | |
+| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
+| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
+| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
+| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
+| 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 | [] |
+| Y_PROVIDER_API_BASE_URL | Y Provider url | |
+| Y_PROVIDER_API_KEY | Y provider API key | |
+
## impress-frontend image
diff --git a/env.d/development/common.dist b/env.d/development/common.dist
index b1c44194b..a0cf0fe5c 100644
--- a/env.d/development/common.dist
+++ b/env.d/development/common.dist
@@ -60,6 +60,7 @@ COLLABORATION_API_URL=http://y-provider-development:4444/collaboration/api/
COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000
COLLABORATION_SERVER_ORIGIN=http://localhost:3000
COLLABORATION_SERVER_SECRET=my-secret
+COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=true
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py
index b9cf1c08e..c75a1f2f8 100644
--- a/src/backend/core/api/serializers.py
+++ b/src/backend/core/api/serializers.py
@@ -239,6 +239,7 @@ class DocumentSerializer(ListDocumentSerializer):
"""Serialize documents with all fields for display in detail views."""
content = serializers.CharField(required=False)
+ websocket = serializers.BooleanField(required=False, write_only=True)
class Meta:
model = models.Document
@@ -260,6 +261,7 @@ class Meta:
"title",
"updated_at",
"user_roles",
+ "websocket",
]
read_only_fields = [
"id",
diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py
index 61d3f20f6..03c1752ce 100644
--- a/src/backend/core/api/viewsets.py
+++ b/src/backend/core/api/viewsets.py
@@ -634,6 +634,83 @@ def perform_destroy(self, instance):
"""Override to implement a soft delete instead of dumping the record in database."""
instance.soft_delete()
+ def _can_user_edit_document(self, document_id, set_cache=False):
+ """Check if the user can edit the document."""
+ try:
+ count, exists = CollaborationService().get_document_connection_info(
+ document_id,
+ self.request.session.session_key,
+ )
+ except requests.HTTPError as e:
+ logger.exception("Failed to call collaboration server: %s", e)
+ count = 0
+ exists = False
+
+ if count == 0:
+ # Nobody is connected to the websocket server
+ logger.debug("update without connection found in the websocket server")
+ cache_key = f"docs:no-websocket:{document_id}"
+ current_editor = cache.get(cache_key)
+
+ if not current_editor:
+ if set_cache:
+ cache.set(
+ cache_key,
+ self.request.session.session_key,
+ settings.NO_WEBSOCKET_CACHE_TIMEOUT,
+ )
+ return True
+
+ if current_editor != self.request.session.session_key:
+ return False
+
+ if set_cache:
+ cache.touch(cache_key, settings.NO_WEBSOCKET_CACHE_TIMEOUT)
+ return True
+
+ if exists:
+ # Current user is connected to the websocket server
+ logger.debug("session key found in the websocket server")
+ return True
+
+ logger.debug(
+ "Users connected to the websocket but current editor not connected to it. Can not edit."
+ )
+
+ return False
+
+ def perform_update(self, serializer):
+ """Check rules about collaboration."""
+ if (
+ serializer.validated_data.get("websocket", False)
+ or not settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY
+ ):
+ return super().perform_update(serializer)
+
+ if self._can_user_edit_document(serializer.instance.id, set_cache=True):
+ return super().perform_update(serializer)
+
+ raise drf.exceptions.PermissionDenied(
+ "You are not allowed to edit this document."
+ )
+
+ @drf.decorators.action(
+ detail=True,
+ methods=["get"],
+ url_path="can-edit",
+ )
+ def can_edit(self, request, *args, **kwargs):
+ """Check if the current user can edit the document."""
+ document = self.get_object()
+
+ can_edit = (
+ True
+ if not settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY
+ else self._can_user_edit_document(document.id)
+ )
+
+ return drf.response.Response({"can_edit": can_edit})
+
@drf.decorators.action(
detail=False,
methods=["get"],
diff --git a/src/backend/core/middleware.py b/src/backend/core/middleware.py
new file mode 100644
index 000000000..afb5a1006
--- /dev/null
+++ b/src/backend/core/middleware.py
@@ -0,0 +1,21 @@
+"""Force session creation for all requests."""
+
+
+class ForceSessionMiddleware:
+ """
+ Force session creation for unauthenticated users.
+ Must be used after Authentication middleware.
+ """
+
+ def __init__(self, get_response):
+ """Initialize the middleware."""
+ self.get_response = get_response
+
+ def __call__(self, request):
+ """Force session creation for unauthenticated users."""
+
+ if not request.user.is_authenticated and request.session.session_key is None:
+ request.session.create()
+
+ response = self.get_response(request)
+ return response
diff --git a/src/backend/core/models.py b/src/backend/core/models.py
index 9d8d2db5f..ead7161e9 100644
--- a/src/backend/core/models.py
+++ b/src/backend/core/models.py
@@ -836,6 +836,7 @@ def get_abilities(self, user, ancestors_links=None):
"ai_translate": ai_access,
"attachment_upload": can_update,
"media_check": can_get,
+ "can_edit": can_update,
"children_list": can_get,
"children_create": can_update and user.is_authenticated,
"collaboration_auth": can_get,
diff --git a/src/backend/core/services/collaboration_services.py b/src/backend/core/services/collaboration_services.py
index dac16fa6d..ae4df1d5e 100644
--- a/src/backend/core/services/collaboration_services.py
+++ b/src/backend/core/services/collaboration_services.py
@@ -41,3 +41,31 @@ def reset_connections(self, room, user_id=None):
f"Failed to notify WebSocket server. Status code: {response.status_code}, "
f"Response: {response.text}"
)
+
+ def get_document_connection_info(self, room, session_key):
+ """
+ Get the connection info for a document.
+ """
+ endpoint = "get-connections"
+ querystring = {
+ "room": room,
+ "sessionKey": session_key,
+ }
+ endpoint_url = f"{settings.COLLABORATION_API_URL}{endpoint}/"
+
+ headers = {"Authorization": settings.COLLABORATION_SERVER_SECRET}
+
+ try:
+ response = requests.get(
+ endpoint_url, headers=headers, params=querystring, timeout=10
+ )
+ except requests.RequestException as e:
+ raise requests.HTTPError("Failed to get document connection info.") from e
+
+ if response.status_code != 200:
+ raise requests.HTTPError(
+ f"Failed to get document connection info. Status code: {response.status_code}, "
+ f"Response: {response.text}"
+ )
+ result = response.json()
+ return result.get("count", 0), result.get("exists", False)
diff --git a/src/backend/core/tests/documents/test_api_documents_can_edit.py b/src/backend/core/tests/documents/test_api_documents_can_edit.py
new file mode 100644
index 000000000..7db0a13c5
--- /dev/null
+++ b/src/backend/core/tests/documents/test_api_documents_can_edit.py
@@ -0,0 +1,248 @@
+"""Test the can_edit endpoint in the viewset DocumentViewSet."""
+
+from django.core.cache import cache
+
+import pytest
+import responses
+from rest_framework.test import APIClient
+
+from core import factories
+
+pytestmark = pytest.mark.django_db
+
+
+@responses.activate
+@pytest.mark.parametrize("ws_not_connected_ready_only", [True, False])
+@pytest.mark.parametrize("role", ["editor", "reader"])
+def test_api_documents_can_edit_anonymous(settings, ws_not_connected_ready_only, role):
+ """Anonymous users can edit documents when link_role is editor."""
+ document = factories.DocumentFactory(link_reach="public", link_role=role)
+ client = APIClient()
+ session_key = client.session.session_key
+ settings.COLLABORATION_API_URL = "http://example.com/"
+ settings.COLLABORATION_SERVER_SECRET = "secret-token"
+ settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = ws_not_connected_ready_only
+ endpoint_url = (
+ f"{settings.COLLABORATION_API_URL}get-connections/"
+ f"?room={document.id}&sessionKey={session_key}"
+ )
+ ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
+
+ response = client.get(f"/api/v1.0/documents/{document.id!s}/can-edit/")
+
+ if role == "reader":
+ assert response.status_code == 401
+ else:
+ assert response.status_code == 200
+ assert response.json() == {"can_edit": True}
+ assert ws_resp.call_count == (1 if ws_not_connected_ready_only else 0)
+
+
+@responses.activate
+@pytest.mark.parametrize("ws_not_connected_ready_only", [True, False])
+def test_api_documents_can_edit_authenticated_no_websocket(
+ settings, ws_not_connected_ready_only
+):
+ """
+ A user not connected to the websocket and no other user have already updated the document,
+ the document can be updated.
+ """
+ user = factories.UserFactory(with_owned_document=True)
+ client = APIClient()
+ client.force_login(user)
+ session_key = client.session.session_key
+
+ document = factories.DocumentFactory(users=[(user, "editor")])
+
+ settings.COLLABORATION_API_URL = "http://example.com/"
+ settings.COLLABORATION_SERVER_SECRET = "secret-token"
+ settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = ws_not_connected_ready_only
+ endpoint_url = (
+ f"{settings.COLLABORATION_API_URL}get-connections/"
+ f"?room={document.id}&sessionKey={session_key}"
+ )
+
+ ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
+
+ assert cache.get(f"docs:no-websocket:{document.id}") is None
+
+ response = client.get(
+ f"/api/v1.0/documents/{document.id!s}/can-edit/",
+ )
+ assert response.status_code == 200
+
+ assert response.json() == {"can_edit": True}
+ assert ws_resp.call_count == (1 if ws_not_connected_ready_only else 0)
+
+
+@responses.activate
+def test_api_documents_can_edit_authenticated_no_websocket_user_already_editing(
+ settings,
+):
+ """
+ A user not connected to the websocket and another user have already updated the document,
+ the document can not be updated.
+ """
+ user = factories.UserFactory(with_owned_document=True)
+ client = APIClient()
+ client.force_login(user)
+ session_key = client.session.session_key
+
+ document = factories.DocumentFactory(users=[(user, "editor")])
+
+ settings.COLLABORATION_API_URL = "http://example.com/"
+ settings.COLLABORATION_SERVER_SECRET = "secret-token"
+ settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
+ endpoint_url = (
+ f"{settings.COLLABORATION_API_URL}get-connections/"
+ f"?room={document.id}&sessionKey={session_key}"
+ )
+ ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
+
+ cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
+
+ response = client.get(
+ f"/api/v1.0/documents/{document.id!s}/can-edit/",
+ )
+ assert response.status_code == 200
+ assert response.json() == {"can_edit": False}
+
+ assert ws_resp.call_count == 1
+
+
+@responses.activate
+def test_api_documents_can_edit_no_websocket_other_user_connected_to_websocket(
+ settings,
+):
+ """
+ A user not connected to the websocket and another user is connected to the websocket,
+ the document can not be updated.
+ """
+ user = factories.UserFactory(with_owned_document=True)
+ client = APIClient()
+ client.force_login(user)
+ session_key = client.session.session_key
+
+ document = factories.DocumentFactory(users=[(user, "editor")])
+
+ settings.COLLABORATION_API_URL = "http://example.com/"
+ settings.COLLABORATION_SERVER_SECRET = "secret-token"
+ settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
+ endpoint_url = (
+ f"{settings.COLLABORATION_API_URL}get-connections/"
+ f"?room={document.id}&sessionKey={session_key}"
+ )
+ ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False})
+
+ assert cache.get(f"docs:no-websocket:{document.id}") is None
+
+ response = client.get(
+ f"/api/v1.0/documents/{document.id!s}/can-edit/",
+ )
+ assert response.status_code == 200
+ assert response.json() == {"can_edit": False}
+ assert cache.get(f"docs:no-websocket:{document.id}") is None
+ assert ws_resp.call_count == 1
+
+
+@responses.activate
+def test_api_documents_can_edit_user_connected_to_websocket(settings):
+ """
+ A user connected to the websocket, the document can be updated.
+ """
+ user = factories.UserFactory(with_owned_document=True)
+ client = APIClient()
+ client.force_login(user)
+ session_key = client.session.session_key
+
+ document = factories.DocumentFactory(users=[(user, "editor")])
+
+ settings.COLLABORATION_API_URL = "http://example.com/"
+ settings.COLLABORATION_SERVER_SECRET = "secret-token"
+ settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
+ endpoint_url = (
+ f"{settings.COLLABORATION_API_URL}get-connections/"
+ f"?room={document.id}&sessionKey={session_key}"
+ )
+ ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
+
+ assert cache.get(f"docs:no-websocket:{document.id}") is None
+
+ response = client.get(
+ f"/api/v1.0/documents/{document.id!s}/can-edit/",
+ )
+ assert response.status_code == 200
+ assert response.json() == {"can_edit": True}
+ assert cache.get(f"docs:no-websocket:{document.id}") is None
+ assert ws_resp.call_count == 1
+
+
+@responses.activate
+def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket(
+ settings,
+):
+ """
+ When the websocket server is unreachable, the document can be updated like if the user was
+ not connected to the websocket.
+ """
+ user = factories.UserFactory(with_owned_document=True)
+ client = APIClient()
+ client.force_login(user)
+ session_key = client.session.session_key
+
+ document = factories.DocumentFactory(users=[(user, "editor")])
+
+ settings.COLLABORATION_API_URL = "http://example.com/"
+ settings.COLLABORATION_SERVER_SECRET = "secret-token"
+ settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
+ endpoint_url = (
+ f"{settings.COLLABORATION_API_URL}get-connections/"
+ f"?room={document.id}&sessionKey={session_key}"
+ )
+ ws_resp = responses.get(endpoint_url, status=500)
+
+ assert cache.get(f"docs:no-websocket:{document.id}") is None
+
+ response = client.get(
+ f"/api/v1.0/documents/{document.id!s}/can-edit/",
+ )
+ assert response.status_code == 200
+ assert response.json() == {"can_edit": True}
+
+ assert ws_resp.call_count == 1
+
+
+@responses.activate
+def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket_other_users(
+ settings,
+):
+ """
+ When the websocket server is unreachable, the behavior fallback to the no websocket one.
+ If an other user is already editing, the document can not be updated.
+ """
+ user = factories.UserFactory(with_owned_document=True)
+ client = APIClient()
+ client.force_login(user)
+ session_key = client.session.session_key
+
+ document = factories.DocumentFactory(users=[(user, "editor")])
+
+ settings.COLLABORATION_API_URL = "http://example.com/"
+ settings.COLLABORATION_SERVER_SECRET = "secret-token"
+ settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
+ endpoint_url = (
+ f"{settings.COLLABORATION_API_URL}get-connections/"
+ f"?room={document.id}&sessionKey={session_key}"
+ )
+ ws_resp = responses.get(endpoint_url, status=500)
+
+ cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
+
+ response = client.get(
+ f"/api/v1.0/documents/{document.id!s}/can-edit/",
+ )
+ assert response.status_code == 200
+ assert response.json() == {"can_edit": False}
+
+ assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
+ assert ws_resp.call_count == 1
diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py
index 80b135d3b..fbecf2f5b 100644
--- a/src/backend/core/tests/documents/test_api_documents_retrieve.py
+++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py
@@ -31,6 +31,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"ai_transform": False,
"ai_translate": False,
"attachment_upload": document.link_role == "editor",
+ "can_edit": document.link_role == "editor",
"children_create": False,
"children_list": True,
"collaboration_auth": True,
@@ -99,6 +100,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
"ai_transform": False,
"ai_translate": False,
"attachment_upload": grand_parent.link_role == "editor",
+ "can_edit": grand_parent.link_role == "editor",
"children_create": False,
"children_list": True,
"collaboration_auth": True,
@@ -196,6 +198,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
+ "can_edit": document.link_role == "editor",
"children_create": document.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
@@ -271,6 +274,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"ai_transform": grand_parent.link_role == "editor",
"ai_translate": grand_parent.link_role == "editor",
"attachment_upload": grand_parent.link_role == "editor",
+ "can_edit": grand_parent.link_role == "editor",
"children_create": grand_parent.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
@@ -452,6 +456,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
"ai_transform": access.role != "reader",
"ai_translate": access.role != "reader",
"attachment_upload": access.role != "reader",
+ "can_edit": access.role != "reader",
"children_create": access.role != "reader",
"children_list": True,
"collaboration_auth": True,
diff --git a/src/backend/core/tests/documents/test_api_documents_trashbin.py b/src/backend/core/tests/documents/test_api_documents_trashbin.py
index 4e4eb2769..61ccc0214 100644
--- a/src/backend/core/tests/documents/test_api_documents_trashbin.py
+++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py
@@ -75,6 +75,7 @@ def test_api_documents_trashbin_format():
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
+ "can_edit": True,
"children_create": True,
"children_list": True,
"collaboration_auth": True,
diff --git a/src/backend/core/tests/documents/test_api_documents_update.py b/src/backend/core/tests/documents/test_api_documents_update.py
index 1c583bc95..03b1891bd 100644
--- a/src/backend/core/tests/documents/test_api_documents_update.py
+++ b/src/backend/core/tests/documents/test_api_documents_update.py
@@ -5,8 +5,10 @@
import random
from django.contrib.auth.models import AnonymousUser
+from django.core.cache import cache
import pytest
+import responses
from rest_framework.test import APIClient
from core import factories, models
@@ -44,6 +46,7 @@ def test_api_documents_update_anonymous_forbidden(reach, role, via_parent):
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
+ new_document_values["websocket"] = True
response = APIClient().put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
@@ -90,8 +93,9 @@ def test_api_documents_update_authenticated_unrelated_forbidden(
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
- instance=factories.DocumentFactory()
+ instance=factories.DocumentFactory(),
).data
+ new_document_values["websocket"] = True
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
@@ -141,8 +145,9 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
- instance=factories.DocumentFactory()
+ instance=factories.DocumentFactory(),
).data
+ new_document_values["websocket"] = True
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
@@ -206,6 +211,7 @@ def test_api_documents_update_authenticated_reader(via, via_parent, mock_user_te
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
+ new_document_values["websocket"] = True
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
@@ -258,6 +264,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
+ new_document_values["websocket"] = True
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
@@ -287,6 +294,318 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
assert value == new_document_values[key]
+@responses.activate
+def test_api_documents_update_authenticated_no_websocket(settings):
+ """
+ When a user updates the document, not connected to the websocket and is the first to update,
+ the document should be updated.
+ """
+ user = factories.UserFactory(with_owned_document=True)
+ client = APIClient()
+ client.force_login(user)
+ session_key = client.session.session_key
+
+ document = factories.DocumentFactory(users=[(user, "editor")])
+
+ new_document_values = serializers.DocumentSerializer(
+ instance=factories.DocumentFactory()
+ ).data
+ new_document_values["websocket"] = False
+ settings.COLLABORATION_API_URL = "http://example.com/"
+ settings.COLLABORATION_SERVER_SECRET = "secret-token"
+ settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
+ endpoint_url = (
+ f"{settings.COLLABORATION_API_URL}get-connections/"
+ f"?room={document.id}&sessionKey={session_key}"
+ )
+
+ ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
+
+ assert cache.get(f"docs:no-websocket:{document.id}") is None
+
+ response = client.put(
+ f"/api/v1.0/documents/{document.id!s}/",
+ new_document_values,
+ format="json",
+ )
+ assert response.status_code == 200
+
+ assert cache.get(f"docs:no-websocket:{document.id}") == session_key
+ assert ws_resp.call_count == 1
+
+
+@responses.activate
+def test_api_documents_update_authenticated_no_websocket_user_already_editing(settings):
+ """
+ When a user updates the document, not connected to the websocket and is not the first to update,
+ the document should not be updated.
+ """
+ user = factories.UserFactory(with_owned_document=True)
+ client = APIClient()
+ client.force_login(user)
+ session_key = client.session.session_key
+
+ document = factories.DocumentFactory(users=[(user, "editor")])
+
+ new_document_values = serializers.DocumentSerializer(
+ instance=factories.DocumentFactory()
+ ).data
+ new_document_values["websocket"] = False
+ settings.COLLABORATION_API_URL = "http://example.com/"
+ settings.COLLABORATION_SERVER_SECRET = "secret-token"
+ settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
+ endpoint_url = (
+ f"{settings.COLLABORATION_API_URL}get-connections/"
+ f"?room={document.id}&sessionKey={session_key}"
+ )
+ ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
+
+ cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
+
+ response = client.put(
+ f"/api/v1.0/documents/{document.id!s}/",
+ new_document_values,
+ format="json",
+ )
+ assert response.status_code == 403
+ assert response.json() == {"detail": "You are not allowed to edit this document."}
+
+ assert ws_resp.call_count == 1
+
+
+@responses.activate
+def test_api_documents_update_no_websocket_other_user_connected_to_websocket(settings):
+ """
+ When a user updates the document, not connected to the websocket and another user is connected
+ to the websocket, the document should not be updated.
+ """
+ user = factories.UserFactory(with_owned_document=True)
+ client = APIClient()
+ client.force_login(user)
+ session_key = client.session.session_key
+
+ document = factories.DocumentFactory(users=[(user, "editor")])
+
+ new_document_values = serializers.DocumentSerializer(
+ instance=factories.DocumentFactory()
+ ).data
+ new_document_values["websocket"] = False
+ settings.COLLABORATION_API_URL = "http://example.com/"
+ settings.COLLABORATION_SERVER_SECRET = "secret-token"
+ settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
+ endpoint_url = (
+ f"{settings.COLLABORATION_API_URL}get-connections/"
+ f"?room={document.id}&sessionKey={session_key}"
+ )
+ ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False})
+
+ assert cache.get(f"docs:no-websocket:{document.id}") is None
+
+ response = client.put(
+ f"/api/v1.0/documents/{document.id!s}/",
+ new_document_values,
+ format="json",
+ )
+ assert response.status_code == 403
+ assert response.json() == {"detail": "You are not allowed to edit this document."}
+ assert cache.get(f"docs:no-websocket:{document.id}") is None
+ assert ws_resp.call_count == 1
+
+
+@responses.activate
+def test_api_documents_update_user_connected_to_websocket(settings):
+ """
+ When a user updates the document, connected to the websocket, the document should be updated.
+ """
+ user = factories.UserFactory(with_owned_document=True)
+ client = APIClient()
+ client.force_login(user)
+ session_key = client.session.session_key
+
+ document = factories.DocumentFactory(users=[(user, "editor")])
+
+ new_document_values = serializers.DocumentSerializer(
+ instance=factories.DocumentFactory()
+ ).data
+ new_document_values["websocket"] = False
+ settings.COLLABORATION_API_URL = "http://example.com/"
+ settings.COLLABORATION_SERVER_SECRET = "secret-token"
+ settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
+ endpoint_url = (
+ f"{settings.COLLABORATION_API_URL}get-connections/"
+ f"?room={document.id}&sessionKey={session_key}"
+ )
+ ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
+
+ assert cache.get(f"docs:no-websocket:{document.id}") is None
+
+ response = client.put(
+ f"/api/v1.0/documents/{document.id!s}/",
+ new_document_values,
+ format="json",
+ )
+ assert response.status_code == 200
+ assert cache.get(f"docs:no-websocket:{document.id}") is None
+ assert ws_resp.call_count == 1
+
+
+@responses.activate
+def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websocket(
+ settings,
+):
+ """
+ When the websocket server is unreachable, the document should be updated like if the user was
+ not connected to the websocket.
+ """
+ user = factories.UserFactory(with_owned_document=True)
+ client = APIClient()
+ client.force_login(user)
+ session_key = client.session.session_key
+
+ document = factories.DocumentFactory(users=[(user, "editor")])
+
+ new_document_values = serializers.DocumentSerializer(
+ instance=factories.DocumentFactory()
+ ).data
+ new_document_values["websocket"] = False
+ settings.COLLABORATION_API_URL = "http://example.com/"
+ settings.COLLABORATION_SERVER_SECRET = "secret-token"
+ settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
+ endpoint_url = (
+ f"{settings.COLLABORATION_API_URL}get-connections/"
+ f"?room={document.id}&sessionKey={session_key}"
+ )
+ ws_resp = responses.get(endpoint_url, status=500)
+
+ assert cache.get(f"docs:no-websocket:{document.id}") is None
+
+ response = client.put(
+ f"/api/v1.0/documents/{document.id!s}/",
+ new_document_values,
+ format="json",
+ )
+ assert response.status_code == 200
+
+ assert cache.get(f"docs:no-websocket:{document.id}") == session_key
+ assert ws_resp.call_count == 1
+
+
+@responses.activate
+def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websocket_other_users(
+ settings,
+):
+ """
+ When the websocket server is unreachable, the behavior fallback to the no websocket one.
+ If an other user is already editing, the document should not be updated.
+ """
+ user = factories.UserFactory(with_owned_document=True)
+ client = APIClient()
+ client.force_login(user)
+ session_key = client.session.session_key
+
+ document = factories.DocumentFactory(users=[(user, "editor")])
+
+ new_document_values = serializers.DocumentSerializer(
+ instance=factories.DocumentFactory()
+ ).data
+ new_document_values["websocket"] = False
+ settings.COLLABORATION_API_URL = "http://example.com/"
+ settings.COLLABORATION_SERVER_SECRET = "secret-token"
+ settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
+ endpoint_url = (
+ f"{settings.COLLABORATION_API_URL}get-connections/"
+ f"?room={document.id}&sessionKey={session_key}"
+ )
+ ws_resp = responses.get(endpoint_url, status=500)
+
+ cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
+
+ response = client.put(
+ f"/api/v1.0/documents/{document.id!s}/",
+ new_document_values,
+ format="json",
+ )
+ assert response.status_code == 403
+
+ assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
+ assert ws_resp.call_count == 1
+
+
+@responses.activate
+def test_api_documents_update_force_websocket_param_to_true(settings):
+ """
+ When the websocket parameter is set to true, the document should be updated without any check.
+ """
+ user = factories.UserFactory(with_owned_document=True)
+ client = APIClient()
+ client.force_login(user)
+ session_key = client.session.session_key
+
+ document = factories.DocumentFactory(users=[(user, "editor")])
+
+ new_document_values = serializers.DocumentSerializer(
+ instance=factories.DocumentFactory()
+ ).data
+ new_document_values["websocket"] = True
+ settings.COLLABORATION_API_URL = "http://example.com/"
+ settings.COLLABORATION_SERVER_SECRET = "secret-token"
+ endpoint_url = (
+ f"{settings.COLLABORATION_API_URL}get-connections/"
+ f"?room={document.id}&sessionKey={session_key}"
+ )
+ ws_resp = responses.get(endpoint_url, status=500)
+
+ assert cache.get(f"docs:no-websocket:{document.id}") is None
+
+ response = client.put(
+ f"/api/v1.0/documents/{document.id!s}/",
+ new_document_values,
+ format="json",
+ )
+ assert response.status_code == 200
+
+ assert cache.get(f"docs:no-websocket:{document.id}") is None
+ assert ws_resp.call_count == 0
+
+
+@responses.activate
+def test_api_documents_update_feature_flag_disabled(settings):
+ """
+ When the feature flag is disabled, the document should be updated without any check.
+ """
+ user = factories.UserFactory(with_owned_document=True)
+ client = APIClient()
+ client.force_login(user)
+ session_key = client.session.session_key
+
+ document = factories.DocumentFactory(users=[(user, "editor")])
+
+ new_document_values = serializers.DocumentSerializer(
+ instance=factories.DocumentFactory()
+ ).data
+ new_document_values["websocket"] = False
+ settings.COLLABORATION_API_URL = "http://example.com/"
+ settings.COLLABORATION_SERVER_SECRET = "secret-token"
+ settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = False
+ endpoint_url = (
+ f"{settings.COLLABORATION_API_URL}get-connections/"
+ f"?room={document.id}&sessionKey={session_key}"
+ )
+ ws_resp = responses.get(endpoint_url, status=500)
+
+ assert cache.get(f"docs:no-websocket:{document.id}") is None
+
+ response = client.put(
+ f"/api/v1.0/documents/{document.id!s}/",
+ new_document_values,
+ format="json",
+ )
+ assert response.status_code == 200
+
+ assert cache.get(f"docs:no-websocket:{document.id}") is None
+ assert ws_resp.call_count == 0
+
+
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_teams):
"""
@@ -317,6 +636,7 @@ def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_t
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
+ new_document_values["websocket"] = True
response = client.put(
f"/api/v1.0/documents/{other_document.id!s}/",
new_document_values,
diff --git a/src/backend/core/tests/documents/test_api_documents_update_extract_attachments.py b/src/backend/core/tests/documents/test_api_documents_update_extract_attachments.py
index cf30b5e66..5623749fd 100644
--- a/src/backend/core/tests/documents/test_api_documents_update_extract_attachments.py
+++ b/src/backend/core/tests/documents/test_api_documents_update_extract_attachments.py
@@ -50,7 +50,7 @@ def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_qu
with django_assert_num_queries(11):
response = APIClient().put(
f"/api/v1.0/documents/{document.id!s}/",
- {"content": get_ydoc_with_mages(image_keys)},
+ {"content": get_ydoc_with_mages(image_keys), "websocket": True},
format="json",
)
assert response.status_code == 200
@@ -63,7 +63,7 @@ def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_qu
with django_assert_num_queries(7):
response = APIClient().put(
f"/api/v1.0/documents/{document.id!s}/",
- {"content": get_ydoc_with_mages(image_keys[:2])},
+ {"content": get_ydoc_with_mages(image_keys[:2]), "websocket": True},
format="json",
)
assert response.status_code == 200
diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py
index 1e81e83c8..a791f3d82 100644
--- a/src/backend/core/tests/test_models_documents.py
+++ b/src/backend/core/tests/test_models_documents.py
@@ -155,6 +155,7 @@ def test_models_documents_get_abilities_forbidden(
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
+ "can_edit": False,
"children_create": False,
"children_list": False,
"collaboration_auth": False,
@@ -216,6 +217,7 @@ def test_models_documents_get_abilities_reader(
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
+ "can_edit": False,
"children_create": False,
"children_list": True,
"collaboration_auth": True,
@@ -279,6 +281,7 @@ def test_models_documents_get_abilities_editor(
"ai_transform": is_authenticated,
"ai_translate": is_authenticated,
"attachment_upload": True,
+ "can_edit": True,
"children_create": is_authenticated,
"children_list": True,
"collaboration_auth": True,
@@ -331,6 +334,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
+ "can_edit": True,
"children_create": True,
"children_list": True,
"collaboration_auth": True,
@@ -380,6 +384,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
+ "can_edit": True,
"children_create": True,
"children_list": True,
"collaboration_auth": True,
@@ -432,6 +437,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
+ "can_edit": True,
"children_create": True,
"children_list": True,
"collaboration_auth": True,
@@ -491,6 +497,7 @@ def test_models_documents_get_abilities_reader_user(
"ai_transform": access_from_link and ai_access_setting != "restricted",
"ai_translate": access_from_link and ai_access_setting != "restricted",
"attachment_upload": access_from_link,
+ "can_edit": access_from_link,
"children_create": access_from_link,
"children_list": True,
"collaboration_auth": True,
@@ -548,6 +555,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
+ "can_edit": False,
"children_create": False,
"children_list": True,
"collaboration_auth": True,
diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py
index 7a0635a2f..730574e3d 100755
--- a/src/backend/impress/settings.py
+++ b/src/backend/impress/settings.py
@@ -291,6 +291,7 @@ class Base(Configuration):
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
+ "core.middleware.ForceSessionMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"dockerflow.django.middleware.DockerflowMiddleware",
"csp.middleware.CSPMiddleware",
@@ -480,6 +481,7 @@ class Base(Configuration):
SESSION_COOKIE_AGE = values.PositiveIntegerValue(
default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None
)
+ SESSION_COOKIE_NAME = "docs_sessionid"
# OIDC - Authorization Code Flow
OIDC_CREATE_USER = values.BooleanValue(
@@ -659,6 +661,12 @@ class Base(Configuration):
environ_prefix=None,
)
+ NO_WEBSOCKET_CACHE_TIMEOUT = values.Value(
+ default=120,
+ environ_name="NO_WEBSOCKET_CACHE_TIMEOUT",
+ environ_prefix=None,
+ )
+
# Logging
# We want to make it easy to log to console but by default we log production
# to Sentry and don't want to log to console.
@@ -853,15 +861,9 @@ class Development(Base):
CSRF_TRUSTED_ORIGINS = ["http://localhost:8072", "http://localhost:3000"]
DEBUG = True
- SESSION_COOKIE_NAME = "impress_sessionid"
-
USE_SWAGGER = True
- SESSION_CACHE_ALIAS = "session"
CACHES = {
"default": {
- "BACKEND": "django.core.cache.backends.dummy.DummyCache",
- },
- "session": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": values.Value(
"redis://redis:6379/2",
diff --git a/src/frontend/apps/e2e/__tests__/app-impress/common.ts b/src/frontend/apps/e2e/__tests__/app-impress/common.ts
index c91c42d78..fede1a9b1 100644
--- a/src/frontend/apps/e2e/__tests__/app-impress/common.ts
+++ b/src/frontend/apps/e2e/__tests__/app-impress/common.ts
@@ -6,7 +6,7 @@ export const CONFIG = {
AI_FEATURE_ENABLED: true,
CRISP_WEBSITE_ID: null,
COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/',
- COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: false,
+ COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true,
ENVIRONMENT: 'development',
FRONTEND_CSS_URL: null,
FRONTEND_HOMEPAGE_FEATURE_ENABLED: true,
diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts
index f64139b79..12da91e88 100644
--- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts
@@ -1,14 +1,13 @@
import path from 'path';
-import { expect, test } from '@playwright/test';
+import { chromium, expect, test } from '@playwright/test';
import cs from 'convert-stream';
import {
- CONFIG,
- addNewMember,
createDoc,
goToGridDoc,
mockedDocument,
+ overrideConfig,
verifyDocName,
} from './common';
@@ -522,52 +521,141 @@ test.describe('Doc Editor', () => {
test('it checks block editing when not connected to collab server', async ({
page,
+ browserName,
}) => {
- await page.route('**/api/v1.0/config/', async (route) => {
- const request = route.request();
- if (request.method().includes('GET')) {
- await route.fulfill({
- json: {
- ...CONFIG,
- COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/',
- COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true,
- },
- });
- } else {
- await route.continue();
- }
+ /**
+ * The good port is 4444, but we want to simulate a not connected
+ * collaborative server.
+ * So we use a port that is not used by the collaborative server.
+ * The server will not be able to connect to the collaborative server.
+ */
+ await overrideConfig(page, {
+ COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/',
});
await page.goto('/');
- void page
- .getByRole('button', {
- name: 'New doc',
- })
- .click();
+ const [title] = await createDoc(page, 'editing-blocking', browserName, 1);
const card = page.getByLabel('It is the card information');
await expect(
- card.getByText('Your network do not allow you to edit'),
+ card.getByText('Others are editing. Your network prevent changes.'),
).toBeHidden();
const editor = page.locator('.ProseMirror');
await expect(editor).toHaveAttribute('contenteditable', 'true');
+ let responseCanEditPromise = page.waitForResponse(
+ (response) =>
+ response.url().includes(`/can-edit/`) && response.status() === 200,
+ );
+
await page.getByRole('button', { name: 'Share' }).click();
- await addNewMember(page, 0, 'Editor', 'impress');
+ await page.getByLabel('Visibility', { exact: true }).click();
+
+ await page
+ .getByRole('menuitem', {
+ name: 'Public',
+ })
+ .click();
+
+ await expect(
+ page.getByText('The document visibility has been updated.'),
+ ).toBeVisible();
+
+ await page.getByLabel('Visibility mode').click();
+ await page.getByRole('menuitem', { name: 'Editing' }).click();
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
+ let responseCanEdit = await responseCanEditPromise;
+ expect(responseCanEdit.ok()).toBeTruthy();
+ let jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
+ expect(jsonCanEdit.can_edit).toBeTruthy();
+
+ const urlDoc = page.url();
+
+ /**
+ * We open another browser that will connect to the collaborative server
+ * and will block the current browser to edit the doc.
+ */
+ const otherBrowser = await chromium.launch({ headless: true });
+ const otherContext = await otherBrowser.newContext({
+ locale: 'en-US',
+ timezoneId: 'Europe/Paris',
+ permissions: [],
+ storageState: {
+ cookies: [],
+ origins: [],
+ },
+ });
+ const otherPage = await otherContext.newPage();
+
+ const webSocketPromise = otherPage.waitForEvent(
+ 'websocket',
+ (webSocket) => {
+ return webSocket
+ .url()
+ .includes('ws://localhost:4444/collaboration/ws/?room=');
+ },
+ );
+
+ await otherPage.goto(urlDoc);
+
+ const webSocket = await webSocketPromise;
+ expect(webSocket.url()).toContain(
+ 'ws://localhost:4444/collaboration/ws/?room=',
+ );
+
+ await verifyDocName(otherPage, title);
+
+ await page.reload();
+
+ responseCanEditPromise = page.waitForResponse(
+ (response) =>
+ response.url().includes(`/can-edit/`) && response.status() === 200,
+ );
+
+ responseCanEdit = await responseCanEditPromise;
+ expect(responseCanEdit.ok()).toBeTruthy();
+
+ jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
+ expect(jsonCanEdit.can_edit).toBeFalsy();
+
await expect(
- card.getByText('Your network do not allow you to edit'),
+ card.getByText('Others are editing. Your network prevent changes.'),
).toBeVisible({
timeout: 10000,
});
await expect(editor).toHaveAttribute('contenteditable', 'false');
+
+ await page.getByRole('button', { name: 'Share' }).click();
+
+ await page.getByLabel('Visibility mode').click();
+ await page.getByRole('menuitem', { name: 'Reading' }).click();
+
+ // Close the modal
+ await page.getByRole('button', { name: 'close' }).first().click();
+
+ await page.reload();
+
+ responseCanEditPromise = page.waitForResponse(
+ (response) =>
+ response.url().includes(`/can-edit/`) && response.status() === 200,
+ );
+
+ responseCanEdit = await responseCanEditPromise;
+ expect(responseCanEdit.ok()).toBeTruthy();
+
+ jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
+ expect(jsonCanEdit.can_edit).toBeTruthy();
+
+ await expect(
+ card.getByText('Others are editing. Your network prevent changes.'),
+ ).toBeHidden();
});
test('it checks if callout custom block', async ({ page, browserName }) => {
diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts
index 0eec24556..feaf18a6a 100644
--- a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts
@@ -15,49 +15,11 @@ test.beforeEach(async ({ page }) => {
});
test.describe('Doc Header', () => {
- test('it checks the element are correctly displayed', async ({ page }) => {
- await mockedDocument(page, {
- accesses: [
- {
- id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
- role: 'owner',
- user: {
- email: 'super@owner.com',
- full_name: 'Super Owner',
- },
- },
- {
- id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
- role: 'admin',
- user: {
- email: 'super@admin.com',
- },
- },
- {
- id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
- role: 'owner',
- user: {
- email: 'super2@owner.com',
- },
- },
- ],
- abilities: {
- destroy: true, // Means owner
- link_configuration: true,
- versions_destroy: true,
- versions_list: true,
- versions_retrieve: true,
- accesses_manage: true,
- accesses_view: true,
- update: true,
- partial_update: true,
- retrieve: true,
- },
- link_reach: 'public',
- created_at: '2021-09-01T09:00:00Z',
- });
-
- await goToGridDoc(page);
+ test('it checks the element are correctly displayed', async ({
+ page,
+ browserName,
+ }) => {
+ await createDoc(page, 'doc-update', browserName, 1);
const card = page.getByLabel(
'It is the card information about the document.',
@@ -66,6 +28,18 @@ test.describe('Doc Header', () => {
const docTitle = card.getByRole('textbox', { name: 'doc title input' });
await expect(docTitle).toBeVisible();
+ await page.getByRole('button', { name: 'Share' }).click();
+
+ await page.getByLabel('Visibility', { exact: true }).click();
+
+ await page
+ .getByRole('menuitem', {
+ name: 'Public',
+ })
+ .click();
+
+ await page.getByRole('button', { name: 'close' }).first().click();
+
await expect(card.getByText('Public document')).toBeVisible();
await expect(card.getByText('Owner ·')).toBeVisible();
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx
index 9a486b586..fe419b809 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx
@@ -50,9 +50,10 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { t } = useTranslation();
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
+ const isConnectedToCollabServer = provider.isSynced;
const readOnly = !doc.abilities.partial_update || !isEditable || isLoading;
- useSaveDoc(doc.id, provider.document, !readOnly);
+ useSaveDoc(doc.id, provider.document, !readOnly, isConnectedToCollabServer);
const { i18n } = useTranslation();
const lang = i18n.resolvedLanguage;
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx
index 0a20001db..a2f419381 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx
@@ -41,7 +41,7 @@ describe('useSaveDoc', () => {
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
- renderHook(() => useSaveDoc(docId, yDoc, true), {
+ renderHook(() => useSaveDoc(docId, yDoc, true, true), {
wrapper: AppWrapper,
});
@@ -73,7 +73,7 @@ describe('useSaveDoc', () => {
}),
});
- renderHook(() => useSaveDoc(docId, yDoc, false), {
+ renderHook(() => useSaveDoc(docId, yDoc, false, true), {
wrapper: AppWrapper,
});
@@ -107,7 +107,7 @@ describe('useSaveDoc', () => {
}),
});
- renderHook(() => useSaveDoc(docId, yDoc, true), {
+ renderHook(() => useSaveDoc(docId, yDoc, true, true), {
wrapper: AppWrapper,
});
@@ -143,7 +143,7 @@ describe('useSaveDoc', () => {
}),
});
- renderHook(() => useSaveDoc(docId, yDoc, true), {
+ renderHook(() => useSaveDoc(docId, yDoc, true, true), {
wrapper: AppWrapper,
});
@@ -164,7 +164,7 @@ describe('useSaveDoc', () => {
const docId = 'test-doc-id';
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
- const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true), {
+ const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true, true), {
wrapper: AppWrapper,
});
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx
index 274adcfff..c6ca782e1 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx
@@ -10,7 +10,12 @@ import { toBase64 } from '../utils';
const SAVE_INTERVAL = 60000;
-const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => {
+const useSaveDoc = (
+ docId: string,
+ yDoc: Y.Doc,
+ canSave: boolean,
+ isConnectedToCollabServer: boolean,
+) => {
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
onSuccess: () => {
@@ -49,10 +54,18 @@ const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => {
updateDoc({
id: docId,
content: toBase64(Y.encodeStateAsUpdate(yDoc)),
+ websocket: isConnectedToCollabServer,
});
return true;
- }, [canSave, yDoc, docId, isLocalChange, updateDoc]);
+ }, [
+ canSave,
+ isLocalChange,
+ updateDoc,
+ docId,
+ yDoc,
+ isConnectedToCollabServer,
+ ]);
const router = useRouter();
diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertNetwork.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertNetwork.tsx
index b070356c6..dd9cb3eb4 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertNetwork.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertNetwork.tsx
@@ -32,7 +32,7 @@ export const AlertNetwork = () => {
- {t('Your network do not allow you to edit')}
+ {t('Others are editing. Your network prevent changes.')}
{
$margin={{ top: 'auto' }}
/>
- {t('Know more')}
+ {t('Learn more')}
@@ -74,8 +74,8 @@ export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => {
onClose={() => onClose()}
rightActions={
<>
-