(back) document as for access CRUD

We introduce a new model for user wanted to access a document or upgrade
their role if they already have access.
The viewsets does not implement PUT and PATCH, we don't need it for now.
This commit is contained in:
Manuel Raynaud
2025-06-18 15:13:48 +02:00
parent 2e1b112133
commit c2e46fa9e2
7 changed files with 705 additions and 0 deletions

View File

@@ -665,6 +665,37 @@ class InvitationSerializer(serializers.ModelSerializer):
return role
class DocumentAskForAccessCreateSerializer(serializers.Serializer):
"""Serializer for creating a document ask for access."""
role = serializers.ChoiceField(choices=models.RoleChoices.choices, required=False, default=models.RoleChoices.READER)
class DocumentAskForAccessSerializer(serializers.ModelSerializer):
"""Serializer for document ask for access model"""
abilities = serializers.SerializerMethodField(read_only=True)
user = UserSerializer(read_only=True)
class Meta:
model = models.DocumentAskForAccess
fields = [
"id",
"document",
"user",
"role",
"created_at",
"abilities",
]
read_only_fields = ["id", "document", "user", "role", "created_at", "abilities"]
def get_abilities(self, invitation) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return invitation.get_abilities(request.user)
return {}
class VersionFilterSerializer(serializers.Serializer):
"""Validate version filters applied to the list endpoint."""

View File

@@ -1774,6 +1774,70 @@ class InvitationViewset(
)
class DocumentAskForAccessViewSet(
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for asking for access to a document."""
lookup_field = "id"
pagination_class = Pagination
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
queryset = models.DocumentAskForAccess.objects.all()
serializer_class = serializers.DocumentAskForAccessSerializer
_document = None
def get_document_or_404(self):
"""Get the document related to the viewset or raise a 404 error."""
if self._document is None:
try:
self._document = models.Document.objects.get(
pk=self.kwargs["resource_id"]
)
except models.Document.DoesNotExist as e:
raise drf.exceptions.NotFound("Document not found.") from e
return self._document
def get_queryset(self):
"""Return the queryset according to the action."""
document = self.get_document_or_404()
queryset = super().get_queryset()
queryset = queryset.filter(document=document)
roles = set(document.get_roles(self.request.user))
is_owner_or_admin = bool(roles.intersection(set(models.PRIVILEGED_ROLES)))
if not is_owner_or_admin:
queryset = queryset.filter(user=self.request.user)
return queryset
def create(self, request, *args, **kwargs):
"""Create a document ask for access resource."""
document = self.get_document_or_404()
serializer = serializers.DocumentAskForAccessCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
queryset = self.get_queryset()
if queryset.filter(user=request.user).exists():
return drf.response.Response(
{"detail": "You already ask to access to this document."},
status=drf.status.HTTP_400_BAD_REQUEST,
)
models.DocumentAskForAccess.objects.create(
document=document,
user=request.user,
role=serializer.validated_data["role"],
)
return drf.response.Response(status=drf.status.HTTP_201_CREATED)
class ConfigView(drf.views.APIView):
"""API ViewSet for sharing some public settings."""

View File

@@ -182,6 +182,17 @@ class TeamDocumentAccessFactory(factory.django.DjangoModelFactory):
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class DocumentAskForAccessFactory(factory.django.DjangoModelFactory):
"""Create fake document ask for access for testing."""
class Meta:
model = models.DocumentAskForAccess
document = factory.SubFactory(DocumentFactory)
user = factory.SubFactory(UserFactory)
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class TemplateFactory(factory.django.DjangoModelFactory):
"""A factory to create templates"""

View File

@@ -0,0 +1,89 @@
# Generated by Django 5.2.3 on 2025-06-18 10:02
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0021_activate_unaccent_extension"),
]
operations = [
migrations.CreateModel(
name="DocumentAskForAccess",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
(
"role",
models.CharField(
choices=[
("reader", "Reader"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ask_for_accesses",
to="core.document",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ask_for_accesses",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Document ask for access",
"verbose_name_plural": "Document ask for accesses",
"db_table": "impress_document_ask_for_access",
"constraints": [
models.UniqueConstraint(
fields=("user", "document"),
name="unique_document_ask_for_access_user",
violation_error_message="This user has already asked for access to this document.",
)
],
},
),
]

View File

@@ -1149,6 +1149,65 @@ class DocumentAccess(BaseAccess):
}
class DocumentAskForAccess(BaseModel):
"""Relation model to ask for access to a document."""
document = models.ForeignKey(
Document, on_delete=models.CASCADE, related_name="ask_for_accesses"
)
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="ask_for_accesses"
)
role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER
)
class Meta:
db_table = "impress_document_ask_for_access"
verbose_name = _("Document ask for access")
verbose_name_plural = _("Document ask for accesses")
constraints = [
models.UniqueConstraint(
fields=["user", "document"],
name="unique_document_ask_for_access_user",
violation_error_message=_(
"This user has already asked for access to this document."
),
),
]
def __str__(self):
return f"{self.user!s} asked for access to document {self.document!s}"
def get_abilities(self, user):
"""Compute and return abilities for a given user."""
roles = []
if user.is_authenticated:
teams = user.teams
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = self.document.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (self._meta.model.DoesNotExist, IndexError):
roles = []
is_admin_or_owner = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
return {
"destroy": is_admin_or_owner,
"update": is_admin_or_owner,
"partial_update": is_admin_or_owner,
"retrieve": is_admin_or_owner,
}
class Template(BaseModel):
"""HTML and CSS code used for formatting the print around the MarkDown body."""

View File

@@ -0,0 +1,445 @@
"""Test API for document ask for access."""
import uuid
import pytest
from rest_framework.test import APIClient
from core.api.serializers import UserSerializer
from core.factories import (
DocumentAskForAccessFactory,
DocumentFactory,
UserDocumentAccessFactory,
UserFactory,
)
from core.models import DocumentAccess, DocumentAskForAccess, RoleChoices
pytestmark = pytest.mark.django_db
## Create
def test_api_documents_ask_for_access_create_anonymous():
"""Anonymous users should not be able to create a document ask for access."""
document = DocumentFactory()
client = APIClient()
response = client.post(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 401
def test_api_documents_ask_for_access_create_invalid_document_id():
"""Invalid document ID should return a 404 error."""
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/documents/{uuid.uuid4()}/ask-for-access/")
assert response.status_code == 404
def test_api_documents_ask_for_access_create_authenticated():
"""Authenticated users should be able to create a document ask for access."""
document = DocumentFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 201
assert DocumentAskForAccess.objects.filter(
document=document,
user=user,
role=RoleChoices.READER,
).exists()
def test_api_documents_ask_for_access_create_authenticated_specific_role():
"""
Authenticated users should be able to create a document ask for access with a specific role.
"""
document = DocumentFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/",
data={"role": RoleChoices.EDITOR},
)
assert response.status_code == 201
assert DocumentAskForAccess.objects.filter(
document=document,
user=user,
role=RoleChoices.EDITOR,
).exists()
def test_api_documents_ask_for_access_create_authenticated_already_has_access():
"""Authenticated users with existing access can ask for access with a different role."""
user = UserFactory()
document = DocumentFactory(users=[(user, RoleChoices.READER)])
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/",
data={"role": RoleChoices.EDITOR},
)
assert response.status_code == 201
assert DocumentAskForAccess.objects.filter(
document=document,
user=user,
role=RoleChoices.EDITOR,
).exists()
def test_api_documents_ask_for_access_create_authenticated_already_has_ask_for_access():
"""
Authenticated users with existing ask for access can not ask for a new access on this document.
"""
user = UserFactory()
document = DocumentFactory(users=[(user, RoleChoices.READER)])
DocumentAskForAccessFactory(document=document, user=user, role=RoleChoices.READER)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/",
data={"role": RoleChoices.EDITOR},
)
assert response.status_code == 400
assert response.json() == {"detail": "You already ask to access to this document."}
## List
def test_api_documents_ask_for_access_list_anonymous():
"""Anonymous users should not be able to list document ask for access."""
document = DocumentFactory()
DocumentAskForAccessFactory.create_batch(
3, document=document, role=RoleChoices.READER
)
client = APIClient()
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 401
def test_api_documents_ask_for_access_list_authenticated():
"""Authenticated users should be able to list document ask for access."""
document = DocumentFactory()
DocumentAskForAccessFactory.create_batch(
3, document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(UserFactory())
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_documents_ask_for_access_list_authenticated_own_request():
"""Authenticated users should be able to list their own document ask for access."""
document = DocumentFactory()
DocumentAskForAccessFactory.create_batch(
3, document=document, role=RoleChoices.READER
)
user = UserFactory()
user_data = UserSerializer(instance=user).data
document_ask_for_access = DocumentAskForAccessFactory(
document=document, user=user, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 200
assert response.json() == {
"count": 1,
"next": None,
"previous": None,
"results": [
{
"id": str(document_ask_for_access.id),
"document": str(document.id),
"user": user_data,
"role": RoleChoices.READER,
"created_at": document_ask_for_access.created_at.isoformat().replace(
"+00:00", "Z"
),
"abilities": {
"destroy": False,
"update": False,
"partial_update": False,
"retrieve": False,
},
}
],
}
def test_api_documents_ask_for_access_list_authenticated_other_document():
"""Authenticated users should not be able to list document ask for access of other documents."""
document = DocumentFactory()
DocumentAskForAccessFactory.create_batch(
3, document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(UserFactory())
other_document = DocumentFactory()
DocumentAskForAccessFactory.create_batch(
3, document=other_document, role=RoleChoices.READER
)
response = client.get(f"/api/v1.0/documents/{other_document.id}/ask-for-access/")
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR])
def test_api_documents_ask_for_access_list_non_owner_or_admin(role):
"""Non owner or admin users should not be able to list document ask for access."""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
DocumentAskForAccessFactory.create_batch(
3, document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
@pytest.mark.parametrize("role", [RoleChoices.OWNER])
def test_api_documents_ask_for_access_list_owner_or_admin(role):
"""Owner or admin users should be able to list document ask for access."""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_ask_for_accesses = DocumentAskForAccessFactory.create_batch(
3, document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 200
assert response.json() == {
"count": 3,
"next": None,
"previous": None,
"results": [
{
"id": str(document_ask_for_access.id),
"document": str(document.id),
"user": UserSerializer(instance=document_ask_for_access.user).data,
"role": RoleChoices.READER,
"created_at": document_ask_for_access.created_at.isoformat().replace(
"+00:00", "Z"
),
"abilities": {
"destroy": True,
"update": True,
"partial_update": True,
"retrieve": True,
},
}
for document_ask_for_access in document_ask_for_accesses
],
}
## Retrieve
def test_api_documents_ask_for_access_retrieve_anonymous():
"""Anonymous users should not be able to retrieve document ask for access."""
document = DocumentFactory()
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 401
def test_api_documents_ask_for_access_retrieve_authenticated():
"""Authenticated users should not be able to retrieve document ask for access."""
document = DocumentFactory()
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(UserFactory())
response = client.get(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 404
@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR])
def test_api_documents_ask_for_access_retrieve_authenticated_non_owner_or_admin(role):
"""Non owner or admin users should not be able to retrieve document ask for access."""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 404
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_retrieve_owner_or_admin(role):
"""Owner or admin users should be able to retrieve document ask for access."""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
user_data = UserSerializer(instance=document_ask_for_access.user).data
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 200
assert response.json() == {
"id": str(document_ask_for_access.id),
"document": str(document.id),
"user": user_data,
"role": RoleChoices.READER,
"created_at": document_ask_for_access.created_at.isoformat().replace(
"+00:00", "Z"
),
"abilities": {
"destroy": True,
"update": True,
"partial_update": True,
"retrieve": True,
},
}
## Delete
def test_api_documents_ask_for_access_delete_anonymous():
"""Anonymous users should not be able to delete document ask for access."""
document = DocumentFactory()
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
response = client.delete(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 401
def test_api_documents_ask_for_access_delete_authenticated():
"""Authenticated users should not be able to delete document ask for access."""
document = DocumentFactory()
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(UserFactory())
response = client.delete(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 404
@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR])
def test_api_documents_ask_for_access_delete_authenticated_non_owner_or_admin(role):
"""Non owner or admin users should not be able to delete document ask for access."""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 404
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_delete_owner_or_admin(role):
"""Owner or admin users should be able to delete document ask for access."""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 204
assert not DocumentAskForAccess.objects.filter(
id=document_ask_for_access.id
).exists()

View File

@@ -27,6 +27,12 @@ document_related_router.register(
basename="invitations",
)
document_related_router.register(
"ask-for-access",
viewsets.DocumentAskForAccessViewSet,
basename="ask_for_access",
)
# - Routes nested under a template
template_related_router = DefaultRouter()