diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 3ded8e54..c6550aaa 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -538,6 +538,7 @@ class InvitationViewset( mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.DestroyModelMixin, + mixins.UpdateModelMixin, viewsets.GenericViewSet, ): """API ViewSet for user invitations to document. @@ -551,8 +552,9 @@ class InvitationViewset( - role: str [administrator|editor|reader] Return newly created invitation (issuer and document are automatically set) - PUT / PATCH : Not permitted. Instead of updating your invitation, - delete and create a new one. + PATCH /api/v1.0/documents//invitations/:/ with expected data: + - role: str [owner|admin|editor|reader] + Return partially updated document invitation DELETE /api/v1.0/documents//invitations// Delete targeted invitation diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 3e693260..59818582 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -742,13 +742,6 @@ class Invitation(BaseModel): def __str__(self): return f"{self.email} invited to {self.document}" - def save(self, *args, **kwargs): - """Make invitations read-only.""" - if self.created_at: - raise exceptions.PermissionDenied() - - super().save(*args, **kwargs) - def clean(self): """Validate fields.""" super().clean() @@ -771,6 +764,7 @@ class Invitation(BaseModel): def get_abilities(self, user): """Compute and return abilities for a given user.""" can_delete = False + can_update = False roles = [] if user.is_authenticated: @@ -789,9 +783,13 @@ class Invitation(BaseModel): set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}) ) + can_update = bool( + set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}) + ) + return { "destroy": can_delete, - "update": False, - "partial_update": False, + "update": can_update, + "partial_update": can_update, "retrieve": bool(roles), } diff --git a/src/backend/core/tests/documents/test_api_document_invitations.py b/src/backend/core/tests/documents/test_api_document_invitations.py index 60ab5f6d..d6d46bc8 100644 --- a/src/backend/core/tests/documents/test_api_document_invitations.py +++ b/src/backend/core/tests/documents/test_api_document_invitations.py @@ -341,8 +341,8 @@ def test_api_document_invitations__list__authenticated( "is_expired": False, "abilities": { "destroy": role in ["administrator", "owner"], - "update": False, - "partial_update": False, + "update": role in ["administrator", "owner"], + "partial_update": role in ["administrator", "owner"], "retrieve": True, }, } @@ -393,8 +393,8 @@ def test_api_document_invitations__list__expired_invitations_still_listed(settin "is_expired": True, "abilities": { "destroy": True, - "update": False, - "partial_update": False, + "update": True, + "partial_update": True, "retrieve": True, }, }, @@ -468,21 +468,17 @@ def test_api_document_invitations__retrieve__document_member(via, mock_user_get_ "is_expired": False, "abilities": { "destroy": role in ["administrator", "owner"], - "update": False, - "partial_update": False, + "update": role in ["administrator", "owner"], + "partial_update": role in ["administrator", "owner"], "retrieve": True, }, } @pytest.mark.parametrize("via", VIA) -@pytest.mark.parametrize( - "method", - ["put", "patch"], -) -def test_api_document_invitations__update__forbidden(method, via, mock_user_get_teams): +def test_api_document_invitations__put_authenticated(via, mock_user_get_teams): """ - Update of invitations is currently forbidden. + Authenticated user can put invitations. """ user = factories.UserFactory() invitation = factories.InvitationFactory() @@ -496,6 +492,78 @@ def test_api_document_invitations__update__forbidden(method, via, mock_user_get_ document=invitation.document, team="lasuite", role="owner" ) + client = APIClient() + client.force_login(user) + url = f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/" + response = client.patch(url, {"email": "test@test.test"}, format="json") + + assert response.status_code == status.HTTP_200_OK + + invitation.refresh_from_db() + assert invitation.email == "test@test.test" + + +@pytest.mark.parametrize("via", VIA) +def test_api_document_invitations__patch_authenticated(via, mock_user_get_teams): + """ + Authenticated user can patch invitations. + """ + user = factories.UserFactory() + invitation = factories.InvitationFactory(role="owner") + if via == USER: + factories.UserDocumentAccessFactory( + document=invitation.document, user=user, role="owner" + ) + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=invitation.document, team="lasuite", role="owner" + ) + + assert invitation.role == "owner" + + client = APIClient() + client.force_login(user) + url = f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/" + response = client.patch( + url, + {"role": "reader"}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + + invitation.refresh_from_db() + assert invitation.role == "reader" + + +@pytest.mark.parametrize("via", VIA) +@pytest.mark.parametrize( + "method", + ["put", "patch"], +) +@pytest.mark.parametrize( + "role", + ["editor", "reader"], +) +def test_api_document_invitations__update__forbidden__not_authenticated( + method, via, role, mock_user_get_teams +): + """ + Update of invitations is currently forbidden. + """ + user = factories.UserFactory() + invitation = factories.InvitationFactory() + if via == USER: + factories.UserDocumentAccessFactory( + document=invitation.document, user=user, role=role + ) + elif via == TEAM: + mock_user_get_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=invitation.document, team="lasuite", role=role + ) + client = APIClient() client.force_login(user) url = f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/" @@ -504,8 +572,11 @@ def test_api_document_invitations__update__forbidden(method, via, mock_user_get_ if method == "patch": response = client.patch(url) - assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - assert response.json()["detail"] == f'Method "{method.upper()}" not allowed.' + assert response.status_code == status.HTTP_403_FORBIDDEN + assert ( + response.json()["detail"] + == "You do not have permission to perform this action." + ) def test_api_document_invitations__delete__anonymous(): diff --git a/src/backend/core/tests/test_models_invitations.py b/src/backend/core/tests/test_models_invitations.py index 3013047a..38c99939 100644 --- a/src/backend/core/tests/test_models_invitations.py +++ b/src/backend/core/tests/test_models_invitations.py @@ -19,13 +19,6 @@ pytestmark = pytest.mark.django_db fake = Faker() -def test_models_invitations_readonly_after_create(): - """Existing invitations should be readonly.""" - invitation = factories.InvitationFactory() - with pytest.raises(exceptions.PermissionDenied): - invitation.save() - - def test_models_invitations_email_no_empty_mail(): """The "email" field should not be empty.""" with pytest.raises(exceptions.ValidationError, match="This field cannot be blank"): @@ -217,8 +210,8 @@ def test_models_document_invitations_get_abilities_privileged_member( assert abilities == { "destroy": True, "retrieve": True, - "partial_update": False, - "update": False, + "partial_update": True, + "update": True, }