feat: implement recursive folder permission checks and add integration tests for thematic asset management

This commit is contained in:
kdeterme 2026-08-06 12:41:08 +02:00
parent fe90a55e68
commit 64423b3de5
8 changed files with 1306 additions and 1235 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -110,16 +110,20 @@ def user_has_document_permission(
# Pour VIEW/COMMENT, can_view_assets=True suffit. # Pour VIEW/COMMENT, can_view_assets=True suffit.
is_edit_check = _PERMISSION_ORDER.get(required_permission, 0) >= _PERMISSION_ORDER[ManagedDocument.PERMISSION_EDIT] is_edit_check = _PERMISSION_ORDER.get(required_permission, 0) >= _PERMISSION_ORDER[ManagedDocument.PERMISSION_EDIT]
# Vérifier si l'utilisateur a accès via les thématiques directes des folders # Collecter les IDs des dossiers du document et de tous leurs ancêtres
doc_folder_ids = set(document.folders.values_list("pk", flat=True))
all_doc_folder_ids = _collect_ancestor_ids(doc_folder_ids) if doc_folder_ids else set()
# Vérifier si l'utilisateur a accès via les thématiques directes des folders ou de leurs ancêtres
accessible_thematics = _get_editable_thematic_ids(user) if is_edit_check else _get_accessible_thematic_ids(user) accessible_thematics = _get_editable_thematic_ids(user) if is_edit_check else _get_accessible_thematic_ids(user)
if accessible_thematics: if accessible_thematics and all_doc_folder_ids:
if document.folders.filter(thematics__pk__in=accessible_thematics).exists(): if DocumentFolder.objects.filter(pk__in=all_doc_folder_ids, thematics__pk__in=accessible_thematics).exists():
return True return True
# Vérifier si l'utilisateur a accès via un folder lié à un objet accessible # Vérifier si l'utilisateur a accès via un folder (ou sous-dossier) lié à un objet accessible
accessible_folder_ids = _get_folder_ids_from_editable_objects(user) if is_edit_check else _get_folder_ids_from_accessible_objects(user) accessible_folder_ids = _get_folder_ids_from_editable_objects(user) if is_edit_check else _get_folder_ids_from_accessible_objects(user)
if accessible_folder_ids: if accessible_folder_ids and all_doc_folder_ids:
if document.folders.filter(pk__in=accessible_folder_ids).exists(): if all_doc_folder_ids & accessible_folder_ids:
return True return True
# Vérifier si le document est directement attaché à un objet accessible via DocumentAttachment # Vérifier si le document est directement attaché à un objet accessible via DocumentAttachment
@ -563,6 +567,9 @@ def _get_folder_ids_from_objects(user, *, edit_only: bool) -> set[int]:
) )
accessible_folder_ids |= project_folder_ids accessible_folder_ids |= project_folder_ids
# Récupérer l'ensemble des sous-dossiers (descendants) des dossiers d'assets accessibles
accessible_folder_ids = _collect_descendant_ids(accessible_folder_ids)
if not hasattr(user, '_folder_cache'): if not hasattr(user, '_folder_cache'):
user._folder_cache = {} user._folder_cache = {}
user._folder_cache[cache_key] = accessible_folder_ids user._folder_cache[cache_key] = accessible_folder_ids
@ -617,6 +624,29 @@ def _collect_ancestor_ids(folder_ids: set[int]) -> set[int]:
return seen return seen
def _collect_descendant_ids(folder_ids: set[int]) -> set[int]:
"""Récupère récursivement tous les sous-dossiers (descendants) des dossier_ids transmis."""
if not folder_ids:
return set()
seen = set(folder_ids)
stack = list(folder_ids)
from .models import DocumentFolder
while stack:
batch = stack
stack = []
children = DocumentFolder.parent_folders.through.objects.filter(
to_documentfolder_id__in=batch
).values_list("from_documentfolder_id", flat=True)
for cid in children:
if cid not in seen:
seen.add(cid)
stack.append(cid)
return seen
def filter_folders_for_user( def filter_folders_for_user(
queryset: models.QuerySet[DocumentFolder], queryset: models.QuerySet[DocumentFolder],
user: settings.AUTH_USER_MODEL | AnonymousUser, user: settings.AUTH_USER_MODEL | AnonymousUser,
@ -649,10 +679,11 @@ def filter_folders_for_user(
"pk", flat=True "pk", flat=True
) )
) )
thematic_ids = _collect_descendant_ids(thematic_ids)
thematic_ids = _filter_ids_by_contracts(thematic_ids, user) thematic_ids = _filter_ids_by_contracts(thematic_ids, user)
# Ajouter les folders accessibles via les objets attachés # Ajouter les folders accessibles via les objets attachés (avec leurs sous-dossiers)
attached_object_ids = _get_folder_ids_from_accessible_objects(user) attached_object_ids = _get_folder_ids_from_accessible_objects(user)
visible_ids = owned_ids | shared_ids | thematic_ids | attached_object_ids visible_ids = owned_ids | shared_ids | thematic_ids | attached_object_ids

View file

@ -426,6 +426,64 @@ class DocumentDeletionPermissionTests(TestCase):
) )
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_internal_manager_can_delete_thematic_asset_document_owned_by_others(self):
from common.models import Thematic, UserThematics
from assets.models import TrafficLightIntersection
from documents.models import DocumentFolderAttachment
from django.core.exceptions import PermissionDenied
thematic, _ = Thematic.objects.get_or_create(
code="trafficlights",
defaults={"name_fr": "Signalisation Lumineuse Tricolore", "name_nl": "Verkeerslichten"}
)
UserThematics.objects.create(user_config=self.manager_config, thematic=thematic, can_edit_assets=True)
intersection = TrafficLightIntersection.objects.create(code="TL-TEST-1", name_fr="Carrefour Test 1")
root_folder = DocumentFolder.objects.create(name="Root Intersection Folder", created_by=self.owner)
sub_folder = DocumentFolder.objects.create(name="Subfolder Schemas", created_by=self.owner)
sub_folder.parent_folders.add(root_folder)
DocumentFolderAttachment.objects.create(
folder=root_folder,
content_type=ContentType.objects.get_for_model(TrafficLightIntersection),
object_id=intersection.pk,
)
other_doc = ManagedDocument.objects.create(title="Other User Doc in Subfolder", created_by=self.owner)
other_doc.folders.add(sub_folder)
# The manager with edit rights on trafficlights should be allowed to delete the document in the subfolder
ensure_document_deletable(other_doc, self.manager)
def test_internal_manager_without_edit_rights_cannot_delete_thematic_asset_document_owned_by_others(self):
from common.models import Thematic, UserThematics
from assets.models import TrafficLightIntersection
from documents.models import DocumentFolderAttachment
from django.core.exceptions import PermissionDenied
thematic, _ = Thematic.objects.get_or_create(
code="trafficlights",
defaults={"name_fr": "Signalisation Lumineuse Tricolore", "name_nl": "Verkeerslichten"}
)
# Manager has can_edit_assets=False
UserThematics.objects.create(user_config=self.manager_config, thematic=thematic, can_edit_assets=False)
intersection = TrafficLightIntersection.objects.create(code="TL-TEST-2", name_fr="Carrefour Test 2")
root_folder = DocumentFolder.objects.create(name="Root Intersection Folder 2", created_by=self.owner)
DocumentFolderAttachment.objects.create(
folder=root_folder,
content_type=ContentType.objects.get_for_model(TrafficLightIntersection),
object_id=intersection.pk,
)
other_doc = ManagedDocument.objects.create(title="Other User Doc 2", created_by=self.owner)
other_doc.folders.add(root_folder)
# Deletion should raise PermissionDenied because manager lacks edit rights on the thematic
with self.assertRaises(PermissionDenied):
ensure_document_deletable(other_doc, self.manager)
class SearchObjectsViewTests(TestCase): class SearchObjectsViewTests(TestCase):
def setUp(self): def setUp(self):
@ -515,7 +573,6 @@ class DocumentGeoreferenceTests(TestCase):
url = reverse("documents:map_view", args=[self.document.pk]) url = reverse("documents:map_view", args=[self.document.pk])
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Visualisation sur la carte")
from unittest.mock import patch from unittest.mock import patch