feat: implement document and folder visibility levels with thematic filtering support

This commit is contained in:
kdeterme 2026-08-29 18:52:31 +02:00
parent 577fb1232b
commit 22d0ad9928
10 changed files with 699 additions and 271 deletions

View file

@ -237,6 +237,7 @@ class ExceptionalTransportTests(TestCase):
title="Note de calcul convoi 240t", title="Note de calcul convoi 240t",
created_by=self.superuser created_by=self.superuser
) )
doc.thematics.add(self.thematic_documents)
doc.folders.add(te_folder) doc.folders.add(te_folder)
user_no_te = User.objects.get(pk=self.user_no_te.pk) user_no_te = User.objects.get(pk=self.user_no_te.pk)

View file

@ -34,16 +34,23 @@ class TagField(forms.CharField):
class DocumentUpdateForm(forms.ModelForm): class DocumentUpdateForm(forms.ModelForm):
class Meta: class Meta:
model = ManagedDocument model = ManagedDocument
fields = ["title", "description"] fields = ["title", "description", "visibility", "thematics"]
widgets = { widgets = {
"title": forms.TextInput(attrs={"class": "form-control"}), "title": forms.TextInput(attrs={"class": "form-control"}),
"description": forms.Textarea(attrs={"class": "form-control", "rows": 4}), "description": forms.Textarea(attrs={"class": "form-control", "rows": 4}),
"visibility": forms.Select(attrs={"class": "form-select"}),
"thematics": forms.SelectMultiple(attrs={"class": "form-select"}),
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["title"].widget.attrs.setdefault("class", "form-control") self.fields["title"].widget.attrs.setdefault("class", "form-control")
self.fields["description"].widget.attrs.setdefault("class", "form-control") self.fields["description"].widget.attrs.setdefault("class", "form-control")
self.fields["visibility"].widget.attrs.setdefault("class", "form-select")
self.fields["visibility"].required = False
if "thematics" in self.fields:
self.fields["thematics"].queryset = Thematic.objects.order_by("name_fr")
self.fields["thematics"].required = False
class ManagedDocumentForm(forms.ModelForm): class ManagedDocumentForm(forms.ModelForm):
@ -53,9 +60,11 @@ class ManagedDocumentForm(forms.ModelForm):
class Meta: class Meta:
model = ManagedDocument model = ManagedDocument
fields = ["title", "description", "types", "tags", "folders"] fields = ["title", "description", "visibility", "thematics", "types", "tags", "folders"]
widgets = { widgets = {
"description": forms.Textarea(attrs={"rows": 3}), "description": forms.Textarea(attrs={"rows": 3}),
"visibility": forms.Select(attrs={"class": "form-select"}),
"thematics": forms.SelectMultiple(attrs={"class": "form-select"}),
"types": forms.SelectMultiple(attrs={"class": "form-select"}), "types": forms.SelectMultiple(attrs={"class": "form-select"}),
"tags": forms.SelectMultiple(attrs={"class": "form-select"}), "tags": forms.SelectMultiple(attrs={"class": "form-select"}),
"folders": forms.SelectMultiple(attrs={"class": "form-select"}), "folders": forms.SelectMultiple(attrs={"class": "form-select"}),
@ -65,6 +74,7 @@ class ManagedDocumentForm(forms.ModelForm):
user = kwargs.pop("user", None) user = kwargs.pop("user", None)
initial_folder = kwargs.pop("initial_folder", None) initial_folder = kwargs.pop("initial_folder", None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["visibility"].required = False
# Limiter les folders accessibles à l'utilisateur # Limiter les folders accessibles à l'utilisateur
if user: if user:
@ -335,6 +345,7 @@ class DocumentFolderForm(forms.ModelForm):
fields = [ fields = [
"name", "name",
"description", "description",
"visibility",
"application_label", "application_label",
"parent_folders", "parent_folders",
"thematics", "thematics",
@ -343,6 +354,7 @@ class DocumentFolderForm(forms.ModelForm):
widgets = { widgets = {
"name": forms.TextInput(attrs={"class": "form-control"}), "name": forms.TextInput(attrs={"class": "form-control"}),
"description": forms.Textarea(attrs={"class": "form-control", "rows": 3}), "description": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
"visibility": forms.Select(attrs={"class": "form-select"}),
"application_label": forms.TextInput(attrs={"class": "form-control"}), "application_label": forms.TextInput(attrs={"class": "form-control"}),
"parent_folders": forms.SelectMultiple(attrs={"class": "form-select"}), "parent_folders": forms.SelectMultiple(attrs={"class": "form-select"}),
"thematics": forms.SelectMultiple(attrs={"class": "form-select"}), "thematics": forms.SelectMultiple(attrs={"class": "form-select"}),
@ -352,6 +364,8 @@ class DocumentFolderForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.request_user = kwargs.pop("user", None) self.request_user = kwargs.pop("user", None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if "visibility" in self.fields:
self.fields["visibility"].required = False
instance = getattr(self, "instance", None) instance = getattr(self, "instance", None)
if instance and instance.pk: if instance and instance.pk:

View file

@ -0,0 +1,29 @@
# Generated by Django 6.0.7 on 2026-08-29 16:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0014_userconfig_inspection_list_filters_and_more'),
('documents', '0002_documentattachment_documents_d_content_5921e5_idx_and_more'),
]
operations = [
migrations.AddField(
model_name='documentfolder',
name='visibility',
field=models.CharField(choices=[('private', 'Private (Creator only)'), ('restricted', 'Shared with specific users'), ('internal', 'Internal (Thematics & Contracts)'), ('scoped', 'All qualified users (Internal & External)')], db_index=True, default='internal', max_length=20, verbose_name='Visibility'),
),
migrations.AddField(
model_name='manageddocument',
name='thematics',
field=models.ManyToManyField(blank=True, related_name='managed_documents', to='common.thematic', verbose_name='Thematics'),
),
migrations.AddField(
model_name='manageddocument',
name='visibility',
field=models.CharField(choices=[('private', 'Private (Creator only)'), ('restricted', 'Shared with specific users'), ('internal', 'Internal (Thematics & Contracts)'), ('scoped', 'All qualified users (Internal & External)')], db_index=True, default='internal', max_length=20, verbose_name='Visibility'),
),
]

View file

@ -111,11 +111,25 @@ class DocumentTag(models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
class VisibilityScope(models.TextChoices):
PRIVATE = "private", _("Private (Creator only)")
RESTRICTED = "restricted", _("Shared with specific users")
INTERNAL = "internal", _("Internal (Thematics & Contracts)")
SCOPED = "scoped", _("All qualified users (Internal & External)")
class DocumentFolder(models.Model): class DocumentFolder(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
name = models.CharField(max_length=255, verbose_name=_("Name")) name = models.CharField(max_length=255, verbose_name=_("Name"))
slug = models.SlugField(max_length=150, unique=True, verbose_name=_("Slug")) slug = models.SlugField(max_length=150, unique=True, verbose_name=_("Slug"))
description = models.TextField(blank=True, verbose_name=_("Description")) description = models.TextField(blank=True, verbose_name=_("Description"))
visibility = models.CharField(
max_length=20,
choices=VisibilityScope.choices,
default=VisibilityScope.INTERNAL,
verbose_name=_("Visibility"),
db_index=True,
)
is_predefined = models.BooleanField(default=False, verbose_name=_("Predefined")) is_predefined = models.BooleanField(default=False, verbose_name=_("Predefined"))
application_label = models.CharField( application_label = models.CharField(
max_length=100, blank=True, verbose_name=_("Application label") max_length=100, blank=True, verbose_name=_("Application label")
@ -182,6 +196,13 @@ class ManagedDocument(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
title = models.CharField(max_length=255, verbose_name=_("Title")) title = models.CharField(max_length=255, verbose_name=_("Title"))
description = models.TextField(blank=True, verbose_name=_("Description")) description = models.TextField(blank=True, verbose_name=_("Description"))
visibility = models.CharField(
max_length=20,
choices=VisibilityScope.choices,
default=VisibilityScope.INTERNAL,
verbose_name=_("Visibility"),
db_index=True,
)
created_by = models.ForeignKey( created_by = models.ForeignKey(
User, User,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -221,6 +242,12 @@ class ManagedDocument(models.Model):
types = models.ManyToManyField(DocumentType, blank=True, related_name="documents") types = models.ManyToManyField(DocumentType, blank=True, related_name="documents")
tags = models.ManyToManyField(DocumentTag, blank=True, related_name="documents") tags = models.ManyToManyField(DocumentTag, blank=True, related_name="documents")
thematics = models.ManyToManyField(
"common.Thematic",
blank=True,
related_name="managed_documents",
verbose_name=_("Thematics"),
)
folders = models.ManyToManyField( folders = models.ManyToManyField(
DocumentFolder, DocumentFolder,
related_name="documents", related_name="documents",

View file

@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Iterable from typing import Iterable
from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.db import models from django.db import models
@ -11,9 +12,10 @@ from django.db import models
from .models import ( from .models import (
DocumentFolder, DocumentFolder,
DocumentFolderAttachment, DocumentFolderAttachment,
DocumentAttachment,
ManagedDocument, ManagedDocument,
VisibilityScope,
) )
from django.conf import settings
UserModel = get_user_model() UserModel = get_user_model()
@ -35,7 +37,6 @@ def _normalise_permission(permission: str) -> str:
def _permissions_at_least(permission: str) -> Iterable[str]: def _permissions_at_least(permission: str) -> Iterable[str]:
"""Return every permission value equal or greater than ``permission``.""" """Return every permission value equal or greater than ``permission``."""
level = _PERMISSION_ORDER[_normalise_permission(permission)] level = _PERMISSION_ORDER[_normalise_permission(permission)]
return [perm for perm, perm_level in _PERMISSION_ORDER.items() if perm_level >= level] return [perm for perm, perm_level in _PERMISSION_ORDER.items() if perm_level >= level]
@ -46,35 +47,47 @@ def filter_documents_for_user(
*, *,
required_permission: str = ManagedDocument.PERMISSION_VIEW, required_permission: str = ManagedDocument.PERMISSION_VIEW,
) -> models.QuerySet[ManagedDocument]: ) -> models.QuerySet[ManagedDocument]:
"""Restrict the queryset to documents the user can access.""" """Restrict the queryset to documents the user can access based on document-level permissions."""
if user is None or not getattr(user, "is_authenticated", False): if user is None or not getattr(user, "is_authenticated", False):
return queryset.none() return queryset.none()
if getattr(user, "is_superuser", False): if getattr(user, "is_superuser", False) or user_is_documents_admin(user):
return queryset return queryset
config = getattr(user, "config", None)
is_intern = bool(config and getattr(config, "is_intern", False))
allowed_permissions = list(_permissions_at_least(required_permission)) allowed_permissions = list(_permissions_at_least(required_permission))
is_edit_check = _PERMISSION_ORDER.get(required_permission, 0) >= _PERMISSION_ORDER[ManagedDocument.PERMISSION_EDIT]
filter_q = models.Q(created_by=user) # 1. Accès direct : créateur ou partage direct (DocumentShare)
filter_q |= models.Q(shares__user=user, shares__permission__in=allowed_permissions) base_q = models.Q(created_by=user)
filter_q |= models.Q(folders__shares__user=user, folders__shares__permission__in=allowed_permissions) base_q |= models.Q(shares__user=user, shares__permission__in=allowed_permissions)
# Ajouter les documents des folders accessibles via les thématiques directes (M2M sur le folder) # 2. Documents qualifiés par thématiques & contrats (attachments et direct thematics)
accessible_thematics = _get_accessible_thematic_ids(user) attached_doc_ids = _get_document_ids_from_objects(user, edit_only=is_edit_check)
accessible_thematics = _get_editable_thematic_ids(user) if is_edit_check else _get_accessible_thematic_ids(user)
thematic_doc_ids: set[int] = set()
if accessible_thematics: if accessible_thematics:
filter_q |= models.Q(folders__thematics__pk__in=accessible_thematics) thematic_doc_ids = set(
queryset.filter(thematics__pk__in=accessible_thematics).values_list("pk", flat=True)
)
thematic_doc_ids = _filter_document_ids_by_contracts(thematic_doc_ids, user)
# Ajouter les documents des folders accessibles via les objets attachés qualif_doc_ids = attached_doc_ids | thematic_doc_ids
accessible_folder_ids = _get_folder_ids_from_accessible_objects(user)
if accessible_folder_ids:
filter_q |= models.Q(folders__pk__in=accessible_folder_ids)
# Ajouter les documents directement attachés via DocumentAttachment à un objet accessible if qualif_doc_ids:
# (couvre les documents importés sans folder ou dont le folder n'a pas de DocumentFolderAttachment) internal_q = (
accessible_ct_ids = _get_accessible_content_type_ids(user) models.Q(visibility=VisibilityScope.INTERNAL, pk__in=qualif_doc_ids)
if accessible_ct_ids: if is_intern
filter_q |= models.Q(attachments__content_type_id__in=accessible_ct_ids) else models.Q(pk__in=[])
)
scoped_q = models.Q(visibility=VisibilityScope.SCOPED, pk__in=qualif_doc_ids)
filter_q = base_q | internal_q | scoped_q
else:
filter_q = base_q
result_qs = queryset.filter(filter_q).distinct() result_qs = queryset.filter(filter_q).distinct()
@ -117,61 +130,105 @@ def user_has_document_permission(
if user is None or not getattr(user, "is_authenticated", False): if user is None or not getattr(user, "is_authenticated", False):
return False return False
if getattr(user, "is_superuser", False): if getattr(user, "is_superuser", False) or user_is_documents_admin(user):
return True return True
from assets.permissions import can_view_exceptional_transport from assets.permissions import can_view_exceptional_transport
if not can_view_exceptional_transport(user): if not can_view_exceptional_transport(user):
if ( if document.tags.filter(slug__in=['exceptional_transport', 'exceptional-transport', 'transports-exceptionnels']).exists():
document.tags.filter(slug__in=['exceptional_transport', 'exceptional-transport', 'transports-exceptionnels']).exists() return False
or document.folders.filter( if any(_is_exceptional_transport_folder(f) for f in document.folders.all()):
models.Q(slug__icontains='exceptional-transport')
| models.Q(slug__icontains='transport-exceptionnel')
| models.Q(slug__icontains='transports-exceptionnels')
| models.Q(name__icontains='Exceptional transport')
| models.Q(name__icontains='Transport exceptionnel')
).exists()
):
return False return False
if document.created_by_id and document.created_by_id == getattr(user, "pk", None): if document.created_by_id and document.created_by_id == getattr(user, "pk", None):
return True return True
allowed_permissions = list(_permissions_at_least(required_permission)) allowed_permissions = list(_permissions_at_least(required_permission))
if document.shares.filter(user=user, permission__in=allowed_permissions).exists(): if document.shares.filter(user=user, permission__in=allowed_permissions).exists():
return True return True
if document.folders.filter( # PRIVATE et RESTRICTED ne sont accessibles que via créateur ou partage direct
shares__user=user, shares__permission__in=allowed_permissions if document.visibility in [VisibilityScope.PRIVATE, VisibilityScope.RESTRICTED]:
).exists(): return False
config = getattr(user, "config", None)
is_intern = bool(config and getattr(config, "is_intern", False))
# INTERNAL exige is_intern=True
if document.visibility == VisibilityScope.INTERNAL and not is_intern:
return False
is_edit_check = _PERMISSION_ORDER.get(required_permission, 0) >= _PERMISSION_ORDER[ManagedDocument.PERMISSION_EDIT]
accessible_thematics = _get_editable_thematic_ids(user) if is_edit_check else _get_accessible_thematic_ids(user)
# 1. Vérification thématiques directes
if accessible_thematics and document.thematics.filter(pk__in=accessible_thematics).exists():
if config and config.limit_assets_to_contracts:
doc_contracts = _document_contract_ids(document.pk)
allowed_contract_ids = _get_user_contract_ids(user)
if not doc_contracts or (doc_contracts & allowed_contract_ids):
return True
else:
return True
# 2. Vérification objets attachés (assets, projets, contrats, etc.)
for attachment in document.attachments.all():
content_object = attachment.content_object
if content_object and _user_can_access_content_object(user, content_object, edit_only=is_edit_check):
return True
return False
def user_has_folder_permission(
user: settings.AUTH_USER_MODEL | AnonymousUser,
folder: DocumentFolder,
*,
required_permission: str = ManagedDocument.PERMISSION_VIEW,
) -> bool:
"""Return ``True`` if ``user`` has ``required_permission`` on ``folder``."""
if user is None or not getattr(user, "is_authenticated", False):
return False
if getattr(user, "is_superuser", False) or user_is_documents_admin(user):
return True return True
# Pour les vérifications EDIT+, on exige can_edit_assets=True sur la thématique. from assets.permissions import can_view_exceptional_transport
# Pour VIEW/COMMENT, can_view_assets=True suffit. if not can_view_exceptional_transport(user) and _is_exceptional_transport_folder(folder):
return False
if folder.created_by_id and folder.created_by_id == getattr(user, "pk", None):
return True
allowed_permissions = list(_permissions_at_least(required_permission))
if folder.shares.filter(user=user, permission__in=allowed_permissions).exists():
return True
if folder.visibility in [VisibilityScope.PRIVATE, VisibilityScope.RESTRICTED]:
return False
config = getattr(user, "config", None)
is_intern = bool(config and getattr(config, "is_intern", False))
if folder.visibility == VisibilityScope.INTERNAL and not is_intern:
return False
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]
# 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 and all_doc_folder_ids:
if DocumentFolder.objects.filter(pk__in=all_doc_folder_ids, thematics__pk__in=accessible_thematics).exists(): if accessible_thematics and folder.thematics.filter(pk__in=accessible_thematics).exists():
if config and config.limit_assets_to_contracts:
folder_contracts = _folder_contract_ids(folder.pk)
allowed_contract_ids = _get_user_contract_ids(user)
if not folder_contracts or (folder_contracts & allowed_contract_ids):
return True
else:
return True return True
# Vérifier si l'utilisateur a accès via un folder (ou sous-dossier) lié à un objet accessible for attachment in folder.attachments.all():
accessible_folder_ids = _get_folder_ids_from_editable_objects(user) if is_edit_check else _get_folder_ids_from_accessible_objects(user) content_object = attachment.content_object
if accessible_folder_ids and all_doc_folder_ids: if content_object and _user_can_access_content_object(user, content_object, edit_only=is_edit_check):
if all_doc_folder_ids & accessible_folder_ids:
return True
# Vérifier si le document est directement attaché à un objet accessible via DocumentAttachment
# (couvre les documents importés sans folder ou dont le folder n'a pas de DocumentFolderAttachment)
accessible_ct_ids = _get_editable_content_type_ids(user) if is_edit_check else _get_accessible_content_type_ids(user)
if accessible_ct_ids:
if document.attachments.filter(content_type_id__in=accessible_ct_ids).exists():
return True return True
return False return False
@ -185,8 +242,7 @@ def user_is_documents_admin(user) -> bool:
config = getattr(user, "config", None) config = getattr(user, "config", None)
if config is None: if config is None:
return False return False
# Seuls les admins ont tous les droits d'administration sur les répertoires et documents if config.roles.filter(name__in=["admin", "operator"]).exists():
if config.roles.filter(name="admin").exists():
return True return True
return False return False
@ -214,8 +270,6 @@ def user_can_delete_document(
return user_has_document_permission(user, document, required_permission=ManagedDocument.PERMISSION_EDIT) return user_has_document_permission(user, document, required_permission=ManagedDocument.PERMISSION_EDIT)
def _get_accessible_thematic_ids(user) -> set[int]: def _get_accessible_thematic_ids(user) -> set[int]:
config = getattr(user, "config", None) config = getattr(user, "config", None)
if not config: if not config:
@ -254,19 +308,17 @@ def _folder_contract_ids(folder_id: int) -> set[int]:
.all() .all()
) )
contract_ids: set[int] = set() contract_ids: set[int] = set()
# Grouper les attachments par content_type pour optimiser les requêtes
from collections import defaultdict from collections import defaultdict
attachments_by_type = defaultdict(list) attachments_by_type = defaultdict(list)
for attachment in attachments: for attachment in attachments:
content_type = attachment.content_type content_type = attachment.content_type
if content_type.app_label == "contracts" and content_type.model == "contract": if content_type.app_label == "contracts" and content_type.model == "contract":
contract_ids.add(attachment.object_id) contract_ids.add(attachment.object_id)
else: else:
attachments_by_type[content_type.id].append(attachment.object_id) attachments_by_type[content_type.id].append(attachment.object_id)
# Pour chaque type de contenu, faire une seule requête pour tous les objets
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
for content_type_id, object_ids in attachments_by_type.items(): for content_type_id, object_ids in attachments_by_type.items():
try: try:
@ -274,20 +326,57 @@ def _folder_contract_ids(folder_id: int) -> set[int]:
model_class = content_type.model_class() model_class = content_type.model_class()
if model_class is None: if model_class is None:
continue continue
# Récupérer tous les objets d'un coup avec leurs contrats
objects = model_class.objects.filter(pk__in=object_ids) objects = model_class.objects.filter(pk__in=object_ids)
# Vérifier si le modèle a un champ contract ou contract_id
if hasattr(model_class, 'contract_id'): if hasattr(model_class, 'contract_id'):
objects = objects.values_list('contract_id', flat=True) objects = objects.values_list('contract_id', flat=True)
contract_ids.update(cid for cid in objects if cid) contract_ids.update(cid for cid in objects if cid)
elif hasattr(model_class, 'contract'): elif hasattr(model_class, 'contract'):
objects = objects.select_related('contract').values_list('contract__pk', flat=True) objects = objects.select_related('contract').values_list('contract__pk', flat=True)
contract_ids.update(cid for cid in objects if cid) contract_ids.update(cid for cid in objects if cid)
except Exception: # pragma: no cover - defensive except Exception:
continue continue
return contract_ids
def _document_contract_ids(document_id: int) -> set[int]:
"""Récupère les contract IDs associés à un document (via ses attachments)."""
attachments = (
DocumentAttachment.objects.filter(document_id=document_id)
.select_related("content_type")
.all()
)
contract_ids: set[int] = set()
from collections import defaultdict
attachments_by_type = defaultdict(list)
for attachment in attachments:
content_type = attachment.content_type
if content_type.app_label == "contracts" and content_type.model == "contract":
contract_ids.add(attachment.object_id)
else:
attachments_by_type[content_type.id].append(attachment.object_id)
from django.contrib.contenttypes.models import ContentType
for content_type_id, object_ids in attachments_by_type.items():
try:
content_type = ContentType.objects.get(pk=content_type_id)
model_class = content_type.model_class()
if model_class is None:
continue
objects = model_class.objects.filter(pk__in=object_ids)
if hasattr(model_class, 'contract_id'):
objects = objects.values_list('contract_id', flat=True)
contract_ids.update(cid for cid in objects if cid)
elif hasattr(model_class, 'contract'):
objects = objects.select_related('contract').values_list('contract__pk', flat=True)
contract_ids.update(cid for cid in objects if cid)
except Exception:
continue
return contract_ids return contract_ids
@ -297,25 +386,20 @@ def _filter_ids_by_contracts(folder_ids: set[int], user) -> set[int]:
return folder_ids return folder_ids
allowed_contract_ids = _get_user_contract_ids(user) allowed_contract_ids = _get_user_contract_ids(user)
if not allowed_contract_ids:
return set()
# Optimisation: traiter tous les folders en batch
from collections import defaultdict from collections import defaultdict
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
# Récupérer tous les attachments pour tous les folders en une seule requête
attachments = DocumentFolderAttachment.objects.filter( attachments = DocumentFolderAttachment.objects.filter(
folder_id__in=folder_ids folder_id__in=folder_ids
).select_related('content_type').values( ).select_related('content_type').values(
'folder_id', 'content_type_id', 'content_type__app_label', 'folder_id', 'content_type_id', 'content_type__app_label',
'content_type__model', 'object_id' 'content_type__model', 'object_id'
) )
folder_contracts = defaultdict(set) folder_contracts = defaultdict(set)
attachments_by_type = defaultdict(list) attachments_by_type = defaultdict(list)
# Première passe: identifier les contrats directs et grouper les autres par type
for att in attachments: for att in attachments:
folder_id = att['folder_id'] folder_id = att['folder_id']
if att['content_type__app_label'] == 'contracts' and att['content_type__model'] == 'contract': if att['content_type__app_label'] == 'contracts' and att['content_type__model'] == 'contract':
@ -325,18 +409,16 @@ def _filter_ids_by_contracts(folder_ids: set[int], user) -> set[int]:
'folder_id': folder_id, 'folder_id': folder_id,
'object_id': att['object_id'] 'object_id': att['object_id']
}) })
# Deuxième passe: pour chaque type de contenu, récupérer les contrats en batch
for (ct_id, app_label, model_name), att_list in attachments_by_type.items(): for (ct_id, app_label, model_name), att_list in attachments_by_type.items():
try: try:
content_type = ContentType.objects.get(pk=ct_id) content_type = ContentType.objects.get(pk=ct_id)
model_class = content_type.model_class() model_class = content_type.model_class()
if model_class is None: if model_class is None:
continue continue
object_ids = [att['object_id'] for att in att_list] object_ids = [att['object_id'] for att in att_list]
# Récupérer les contract_ids en une seule requête
if hasattr(model_class, 'contract_id'): if hasattr(model_class, 'contract_id'):
objects_with_contracts = model_class.objects.filter( objects_with_contracts = model_class.objects.filter(
pk__in=object_ids pk__in=object_ids
@ -349,24 +431,92 @@ def _filter_ids_by_contracts(folder_ids: set[int], user) -> set[int]:
obj_to_contract = {obj['pk']: obj['contract__pk'] for obj in objects_with_contracts if obj['contract__pk']} obj_to_contract = {obj['pk']: obj['contract__pk'] for obj in objects_with_contracts if obj['contract__pk']}
else: else:
obj_to_contract = {} obj_to_contract = {}
# Associer les contrats aux folders
for att in att_list: for att in att_list:
contract_id = obj_to_contract.get(att['object_id']) contract_id = obj_to_contract.get(att['object_id'])
if contract_id: if contract_id:
folder_contracts[att['folder_id']].add(contract_id) folder_contracts[att['folder_id']].add(contract_id)
except Exception: # pragma: no cover - defensive except Exception:
continue continue
# Filtrer les folders qui ont au moins un contrat autorisé
allowed: set[int] = set() allowed: set[int] = set()
for folder_id in folder_ids: for folder_id in folder_ids:
contract_ids = folder_contracts.get(folder_id, set()) contract_ids = folder_contracts.get(folder_id, set())
if not contract_ids: # Pas de contrats = accessible if not contract_ids:
allowed.add(folder_id) allowed.add(folder_id)
elif contract_ids & allowed_contract_ids: # Au moins un contrat autorisé elif contract_ids & allowed_contract_ids:
allowed.add(folder_id) allowed.add(folder_id)
return allowed
def _filter_document_ids_by_contracts(doc_ids: set[int], user) -> set[int]:
config = getattr(user, "config", None)
if not config or not config.limit_assets_to_contracts:
return doc_ids
allowed_contract_ids = _get_user_contract_ids(user)
from collections import defaultdict
from django.contrib.contenttypes.models import ContentType
attachments = DocumentAttachment.objects.filter(
document_id__in=doc_ids
).select_related('content_type').values(
'document_id', 'content_type_id', 'content_type__app_label',
'content_type__model', 'object_id'
)
doc_contracts = defaultdict(set)
attachments_by_type = defaultdict(list)
for att in attachments:
doc_id = att['document_id']
if att['content_type__app_label'] == 'contracts' and att['content_type__model'] == 'contract':
doc_contracts[doc_id].add(att['object_id'])
else:
attachments_by_type[(att['content_type_id'], att['content_type__app_label'], att['content_type__model'])].append({
'document_id': doc_id,
'object_id': att['object_id']
})
for (ct_id, app_label, model_name), att_list in attachments_by_type.items():
try:
content_type = ContentType.objects.get(pk=ct_id)
model_class = content_type.model_class()
if model_class is None:
continue
object_ids = [att['object_id'] for att in att_list]
if hasattr(model_class, 'contract_id'):
objects_with_contracts = model_class.objects.filter(
pk__in=object_ids
).values('pk', 'contract_id')
obj_to_contract = {obj['pk']: obj['contract_id'] for obj in objects_with_contracts if obj['contract_id']}
elif hasattr(model_class, 'contract'):
objects_with_contracts = model_class.objects.filter(
pk__in=object_ids
).select_related('contract').values('pk', 'contract__pk')
obj_to_contract = {obj['pk']: obj['contract__pk'] for obj in objects_with_contracts if obj['contract__pk']}
else:
obj_to_contract = {}
for att in att_list:
contract_id = obj_to_contract.get(att['object_id'])
if contract_id:
doc_contracts[att['document_id']].add(contract_id)
except Exception:
continue
allowed: set[int] = set()
for doc_id in doc_ids:
contract_ids = doc_contracts.get(doc_id, set())
if not contract_ids:
allowed.add(doc_id)
elif contract_ids & allowed_contract_ids:
allowed.add(doc_id)
return allowed return allowed
@ -412,58 +562,60 @@ _MODEL_TO_THEMATIC: dict[str, str] = {
} }
def _user_can_access_content_object(user, content_object) -> bool: def _user_can_access_content_object(user, content_object, edit_only: bool = False) -> bool:
""" """
Vérifie si l'utilisateur peut accéder à l'objet de contenu (intersection, structure, etc.). Vérifie si l'utilisateur peut accéder à l'objet de contenu (intersection, structure, etc.).
Args:
user: L'utilisateur
content_object: L'objet attaché au folder
Returns:
bool: True si l'utilisateur peut accéder à l'objet
""" """
if content_object is None: if content_object is None:
return False return False
config = getattr(user, "config", None) config = getattr(user, "config", None)
if not config: if not config:
return False return False
# Les admins et operators peuvent tout voir
if config.roles.filter(name__in=['admin', 'operator']).exists(): if config.roles.filter(name__in=['admin', 'operator']).exists():
return True return True
# Déterminer la thématique de l'objet
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from common.models import Thematic, UserThematics from common.models import Thematic, UserThematics
content_type = ContentType.objects.get_for_model(content_object) content_type = ContentType.objects.get_for_model(content_object)
model_name = content_type.model.lower() model_name = content_type.model.lower()
thematic_code = _MODEL_TO_THEMATIC.get(model_name) thematic_code = _MODEL_TO_THEMATIC.get(model_name)
if not thematic_code: if not thematic_code:
# Cas des projets ou contrats ou autres modèles génériques
if content_type.app_label == 'projects' and model_name == 'project':
from projects.models import ProjectUserAccess
if ProjectUserAccess.objects.filter(user=user, project=content_object, can_view=True).exists():
return True
if config.is_intern:
user_them_ids = _get_accessible_thematic_ids(user)
if content_object.thematics.filter(pk__in=user_them_ids).exists():
return True
return False
if content_type.app_label == 'contracts' and model_name == 'contract':
if not config.limit_assets_to_contracts:
return True
allowed_cids = _get_user_contract_ids(user)
return getattr(content_object, 'pk', None) in allowed_cids
return False return False
# Vérifier si l'utilisateur a accès à la thématique
thematic = Thematic.objects.filter(code=thematic_code).first() thematic = Thematic.objects.filter(code=thematic_code).first()
if not thematic: if not thematic:
return False return False
has_access = UserThematics.objects.filter( filter_kwargs = {'user_config': config, 'thematic': thematic, 'can_edit_assets': True} if edit_only else {'user_config': config, 'thematic': thematic, 'can_view_assets': True}
user_config=config, has_access = UserThematics.objects.filter(**filter_kwargs).exists()
thematic=thematic,
can_view_assets=True
).exists()
if not has_access: if not has_access:
return False return False
# Vérifier les restrictions de contrat si applicable
if config.limit_assets_to_contracts: if config.limit_assets_to_contracts:
allowed_contract_ids = _get_user_contract_ids(user) allowed_contract_ids = _get_user_contract_ids(user)
if allowed_contract_ids: if allowed_contract_ids:
# Vérifier si l'objet est lié à un contrat autorisé
contract_id = getattr(content_object, "contract_id", None) contract_id = getattr(content_object, "contract_id", None)
if contract_id and contract_id not in allowed_contract_ids: if contract_id and contract_id not in allowed_contract_ids:
return False return False
@ -472,30 +624,19 @@ def _user_can_access_content_object(user, content_object) -> bool:
pk = getattr(contract, "pk", None) pk = getattr(contract, "pk", None)
if pk and pk not in allowed_contract_ids: if pk and pk not in allowed_contract_ids:
return False return False
return True return True
def _get_accessible_content_type_ids(user) -> list[int]: def _get_accessible_content_type_ids(user) -> list[int]:
"""
Retourne les IDs des ContentType correspondant aux modèles accessibles
par l'utilisateur via ses thématiques (can_view_assets=True).
Résultat mis en cache sur le user pour la durée de la requête.
"""
return _get_content_type_ids_for_user(user, edit_only=False) return _get_content_type_ids_for_user(user, edit_only=False)
def _get_editable_content_type_ids(user) -> list[int]: def _get_editable_content_type_ids(user) -> list[int]:
"""
Retourne les IDs des ContentType correspondant aux modèles pour lesquels
l'utilisateur a le droit d'édition (can_edit_assets=True).
"""
return _get_content_type_ids_for_user(user, edit_only=True) return _get_content_type_ids_for_user(user, edit_only=True)
def _get_content_type_ids_for_user(user, *, edit_only: bool) -> list[int]: def _get_content_type_ids_for_user(user, *, edit_only: bool) -> list[int]:
"""Implémentation commune pour view et edit content-type IDs."""
cache_key = f'_{"editable" if edit_only else "accessible"}_ct_ids_{getattr(user, "pk", None)}' cache_key = f'_{"editable" if edit_only else "accessible"}_ct_ids_{getattr(user, "pk", None)}'
if hasattr(user, '_folder_cache') and cache_key in user._folder_cache: if hasattr(user, '_folder_cache') and cache_key in user._folder_cache:
return user._folder_cache[cache_key] return user._folder_cache[cache_key]
@ -530,22 +671,8 @@ def _get_content_type_ids_for_user(user, *, edit_only: bool) -> list[int]:
return result return result
def _get_folder_ids_from_accessible_objects(user) -> set[int]:
"""
Récupère les IDs des folders auxquels l'utilisateur a accès (visualisation) via les objets attachés.
"""
return _get_folder_ids_from_objects(user, edit_only=False)
def _get_folder_ids_from_editable_objects(user) -> set[int]:
"""
Récupère les IDs des folders pour lesquels l'utilisateur a le droit d'édition via les objets attachés.
"""
return _get_folder_ids_from_objects(user, edit_only=True)
def _get_folder_ids_from_objects(user, *, edit_only: bool) -> set[int]: def _get_folder_ids_from_objects(user, *, edit_only: bool) -> set[int]:
"""Implémentation commune pour view et edit folder IDs via objets attachés.""" """Folder IDs via DocumentFolderAttachment attachés à des objets accessibles."""
cache_key = f'_{"editable" if edit_only else "accessible"}_folder_ids_{getattr(user, "pk", None)}' cache_key = f'_{"editable" if edit_only else "accessible"}_folder_ids_{getattr(user, "pk", None)}'
if hasattr(user, '_folder_cache') and cache_key in user._folder_cache: if hasattr(user, '_folder_cache') and cache_key in user._folder_cache:
return user._folder_cache[cache_key] return user._folder_cache[cache_key]
@ -553,37 +680,26 @@ def _get_folder_ids_from_objects(user, *, edit_only: bool) -> set[int]:
accessible_folder_ids: set[int] = set() accessible_folder_ids: set[int] = set()
config = getattr(user, "config", None) config = getattr(user, "config", None)
if not config: if not config or user_is_documents_admin(user):
return accessible_folder_ids
if user_is_documents_admin(user):
return accessible_folder_ids return accessible_folder_ids
accessible_content_types = _get_editable_content_type_ids(user) if edit_only else _get_accessible_content_type_ids(user) accessible_content_types = _get_editable_content_type_ids(user) if edit_only else _get_accessible_content_type_ids(user)
if not accessible_content_types: if accessible_content_types:
# Pas de content_types thématiques — mais on continue pour les projets
pass
else:
# Récupérer les folder_ids associés aux content_types accessibles
attachments = DocumentFolderAttachment.objects.filter( attachments = DocumentFolderAttachment.objects.filter(
content_type_id__in=accessible_content_types content_type_id__in=accessible_content_types
).values_list('folder_id', flat=True).distinct() ).values_list('folder_id', flat=True).distinct()
accessible_folder_ids = set(attachments) accessible_folder_ids = set(attachments)
# Filtrer par contrats si nécessaire
if config.limit_assets_to_contracts: if config.limit_assets_to_contracts:
allowed_contract_ids = _get_user_contract_ids(user) allowed_contract_ids = _get_user_contract_ids(user)
if allowed_contract_ids: if allowed_contract_ids:
accessible_folder_ids = _filter_ids_by_contracts(accessible_folder_ids, user) accessible_folder_ids = _filter_ids_by_contracts(accessible_folder_ids, user)
# ── Folders de projets ────────────────────────────────────────────── # Folders de projets
# Pour les projets, on ne distingue pas view/edit (pas de notion can_edit_projects).
# On ne les inclut que pour la vérification de visualisation.
if not edit_only: if not edit_only:
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
try: try:
project_ct = ContentType.objects.get(app_label='projects', model='project') project_ct = ContentType.objects.get(app_label='projects', model='project')
except ContentType.DoesNotExist: except ContentType.DoesNotExist:
@ -618,9 +734,6 @@ 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
@ -628,6 +741,76 @@ def _get_folder_ids_from_objects(user, *, edit_only: bool) -> set[int]:
return accessible_folder_ids return accessible_folder_ids
def _get_document_ids_from_objects(user, *, edit_only: bool) -> set[int]:
"""Document IDs via DocumentAttachment attachés à des objets accessibles."""
cache_key = f'_{"editable" if edit_only else "accessible"}_doc_ids_{getattr(user, "pk", None)}'
if hasattr(user, '_folder_cache') and cache_key in user._folder_cache:
return user._folder_cache[cache_key]
accessible_doc_ids: set[int] = set()
config = getattr(user, "config", None)
if not config or user_is_documents_admin(user):
return accessible_doc_ids
accessible_content_types = _get_editable_content_type_ids(user) if edit_only else _get_accessible_content_type_ids(user)
if accessible_content_types:
attachments = DocumentAttachment.objects.filter(
content_type_id__in=accessible_content_types
).values_list('document_id', flat=True).distinct()
accessible_doc_ids = set(attachments)
if config.limit_assets_to_contracts:
allowed_contract_ids = _get_user_contract_ids(user)
if allowed_contract_ids:
accessible_doc_ids = _filter_document_ids_by_contracts(accessible_doc_ids, user)
# Documents de projets
if not edit_only:
from django.contrib.contenttypes.models import ContentType
try:
project_ct = ContentType.objects.get(app_label='projects', model='project')
except ContentType.DoesNotExist:
project_ct = None
if project_ct:
from projects.models import Project, ProjectUserAccess
from common.models import UserThematics
thematic_project_ids: set[int] = set()
accessible_thematics = UserThematics.objects.filter(
user_config=config, can_view_projects=True
)
if config.is_intern and accessible_thematics.exists():
accessible_thematic_objs = list(accessible_thematics.values_list('thematic_id', flat=True))
thematic_project_ids = set(
Project.objects.filter(
thematics__pk__in=accessible_thematic_objs
).values_list('pk', flat=True)
)
member_project_ids = set(
ProjectUserAccess.objects.filter(user=user, can_view=True).values_list('project_id', flat=True)
)
accessible_project_ids = thematic_project_ids | member_project_ids
if accessible_project_ids:
project_doc_ids = set(
DocumentAttachment.objects.filter(
content_type=project_ct,
object_id__in=accessible_project_ids,
).values_list('document_id', flat=True).distinct()
)
accessible_doc_ids |= project_doc_ids
if not hasattr(user, '_folder_cache'):
user._folder_cache = {}
user._folder_cache[cache_key] = accessible_doc_ids
return accessible_doc_ids
def _collect_ancestor_ids(folder_ids: set[int]) -> set[int]: def _collect_ancestor_ids(folder_ids: set[int]) -> set[int]:
if not folder_ids: if not folder_ids:
return set() return set()
@ -635,12 +818,9 @@ def _collect_ancestor_ids(folder_ids: set[int]) -> set[int]:
seen = set(folder_ids) seen = set(folder_ids)
stack = list(folder_ids) stack = list(folder_ids)
# Optimisation: récupérer toutes les relations parent en une seule requête
# au lieu de faire une requête par folder
all_parent_relations = {} all_parent_relations = {}
if stack: if stack:
from .models import DocumentFolder from .models import DocumentFolder
# Récupérer toutes les relations parent_folders pour tous les folders en une seule requête
relations = DocumentFolder.child_folders.through.objects.filter( relations = DocumentFolder.child_folders.through.objects.filter(
from_documentfolder_id__in=folder_ids from_documentfolder_id__in=folder_ids
).values_list('from_documentfolder_id', 'to_documentfolder_id') ).values_list('from_documentfolder_id', 'to_documentfolder_id')
@ -650,12 +830,10 @@ def _collect_ancestor_ids(folder_ids: set[int]) -> set[int]:
all_parent_relations[child_id] = [] all_parent_relations[child_id] = []
all_parent_relations[child_id].append(parent_id) all_parent_relations[child_id].append(parent_id)
# Parcours itératif en utilisant les relations pré-chargées
while stack: while stack:
current_id = stack.pop() current_id = stack.pop()
parent_ids = all_parent_relations.get(current_id, []) parent_ids = all_parent_relations.get(current_id, [])
# Si on découvre de nouveaux parents, on doit aussi charger leurs relations
new_parents = [pid for pid in parent_ids if pid not in seen] new_parents = [pid for pid in parent_ids if pid not in seen]
if new_parents: if new_parents:
new_relations = DocumentFolder.child_folders.through.objects.filter( new_relations = DocumentFolder.child_folders.through.objects.filter(
@ -708,38 +886,45 @@ def filter_folders_for_user(
if user is None or not getattr(user, "is_authenticated", False): if user is None or not getattr(user, "is_authenticated", False):
return queryset.none() return queryset.none()
if user_is_documents_admin(user): if getattr(user, "is_superuser", False) or user_is_documents_admin(user):
return queryset return queryset
config = getattr(user, "config", None)
is_intern = bool(config and getattr(config, "is_intern", False))
allowed_permissions = list(_permissions_at_least(required_permission)) allowed_permissions = list(_permissions_at_least(required_permission))
is_edit_check = _PERMISSION_ORDER.get(required_permission, 0) >= _PERMISSION_ORDER[ManagedDocument.PERMISSION_EDIT]
owned_ids = set( # 1. Base : créateur ou partage direct (DocumentFolderShare)
queryset.filter(created_by=user).values_list("pk", flat=True) base_q = models.Q(created_by=user)
) base_q |= models.Q(shares__user=user, shares__permission__in=allowed_permissions)
shared_ids = set(
queryset.filter(
shares__user=user, shares__permission__in=allowed_permissions
).values_list("pk", flat=True)
)
thematic_ids = set() # 2. Scope qualifié : thématiques directes ou objets attachés (DocumentFolderAttachment)
accessible_thematics = _get_accessible_thematic_ids(user) attached_folder_ids = _get_folder_ids_from_objects(user, edit_only=is_edit_check)
accessible_thematics = _get_editable_thematic_ids(user) if is_edit_check else _get_accessible_thematic_ids(user)
thematic_folder_ids: set[int] = set()
if accessible_thematics: if accessible_thematics:
thematic_ids = set( thematic_folder_ids = set(
queryset.filter(thematics__pk__in=accessible_thematics).values_list( queryset.filter(thematics__pk__in=accessible_thematics).values_list("pk", flat=True)
"pk", flat=True
)
) )
thematic_ids = _collect_descendant_ids(thematic_ids) thematic_folder_ids = _filter_ids_by_contracts(thematic_folder_ids, user)
thematic_ids = _filter_ids_by_contracts(thematic_ids, user) qualif_folder_ids = attached_folder_ids | thematic_folder_ids
# Ajouter les folders accessibles via les objets attachés (avec leurs sous-dossiers) if qualif_folder_ids:
attached_object_ids = _get_folder_ids_from_accessible_objects(user) internal_q = (
models.Q(visibility=VisibilityScope.INTERNAL, pk__in=qualif_folder_ids)
if is_intern
else models.Q(pk__in=[])
)
scoped_q = models.Q(visibility=VisibilityScope.SCOPED, pk__in=qualif_folder_ids)
filter_q = base_q | internal_q | scoped_q
else:
filter_q = base_q
visible_ids = owned_ids | shared_ids | thematic_ids | attached_object_ids visible_ids = set(queryset.filter(filter_q).values_list("pk", flat=True))
if include_ancestors and visible_ids:
if include_ancestors:
visible_ids = _collect_ancestor_ids(visible_ids) visible_ids = _collect_ancestor_ids(visible_ids)
if not visible_ids: if not visible_ids:
@ -763,75 +948,30 @@ def filter_folders_for_user(
def user_can_browse_folder(user, folder: DocumentFolder) -> bool: def user_can_browse_folder(user, folder: DocumentFolder) -> bool:
""" """
Vérifie si l'utilisateur peut naviguer dans le module documents vers ce folder. Vérifie si l'utilisateur peut naviguer dans le module documents vers ce folder.
Cette fonction est utilisée pour déterminer si le lien vers le répertoire
principal doit être affiché. L'utilisateur doit avoir accès au folder via:
- Être admin/superuser
- Être interne (is_intern=True) ET avoir le folder dans ses folders visibles
- Être externe avec can_access_view('documents') ET avoir le folder partagé
Args:
user: L'utilisateur
folder: Le folder à vérifier
Returns:
bool: True si l'utilisateur peut naviguer vers le folder
""" """
if user is None or not getattr(user, "is_authenticated", False): if user is None or not getattr(user, "is_authenticated", False):
return False return False
from assets.permissions import can_view_exceptional_transport
if not can_view_exceptional_transport(user) and _is_exceptional_transport_folder(folder):
return False
config = getattr(user, "config", None)
if not config:
return False
# Les admins peuvent toujours accéder
if user_is_documents_admin(user): if user_is_documents_admin(user):
return True return True
# Les utilisateurs internes (managers, controllers, etc.) ont accès return user_has_folder_permission(user, folder, required_permission=ManagedDocument.PERMISSION_VIEW)
# si le folder est dans leurs folders visibles (filtrage par thématiques/partages)
if config.is_intern:
visible_ids = get_user_visible_folder_ids(user)
return folder.pk in visible_ids
# Les utilisateurs externes doivent avoir l'accès explicite à l'app documents
if not config.can_access_view('documents'):
return False
# Vérifier si le folder a été partagé avec l'utilisateur
allowed_permissions = list(_permissions_at_least(ManagedDocument.PERMISSION_VIEW))
if folder.shares.filter(user=user, permission__in=allowed_permissions).exists():
return True
# Vérifier si l'utilisateur est propriétaire du folder
if folder.created_by_id and folder.created_by_id == getattr(user, "pk", None):
return True
return False
def get_user_visible_folder_ids(user) -> set[int]: def get_user_visible_folder_ids(user) -> set[int]:
"""Récupère les IDs des folders visibles par l'utilisateur (avec cache).""" """Récupère les IDs des folders visibles par l'utilisateur (avec cache)."""
# Utiliser un cache au niveau de la requête
cache_key = '_visible_folder_ids' cache_key = '_visible_folder_ids'
if hasattr(user, '_folder_cache') and cache_key in user._folder_cache: if hasattr(user, '_folder_cache') and cache_key in user._folder_cache:
return user._folder_cache[cache_key] return user._folder_cache[cache_key]
queryset = DocumentFolder.objects.all() queryset = DocumentFolder.objects.all()
visible_qs = filter_folders_for_user( visible_qs = filter_folders_for_user(queryset, user, include_ancestors=True)
queryset, user, include_ancestors=True
)
visible_ids = set(visible_qs.values_list("pk", flat=True)) visible_ids = set(visible_qs.values_list("pk", flat=True))
# Mettre en cache
if not hasattr(user, '_folder_cache'): if not hasattr(user, '_folder_cache'):
user._folder_cache = {} user._folder_cache = {}
user._folder_cache[cache_key] = visible_ids user._folder_cache[cache_key] = visible_ids
return visible_ids return visible_ids
@ -841,5 +981,8 @@ __all__ = [
"get_user_visible_folder_ids", "get_user_visible_folder_ids",
"user_is_documents_admin", "user_is_documents_admin",
"user_has_document_permission", "user_has_document_permission",
"user_has_folder_permission",
"user_can_browse_folder", "user_can_browse_folder",
"user_can_delete_document",
"user_is_internal_manager",
] ]

View file

@ -21,6 +21,15 @@
<div> <div>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<h2 class="mb-1">{{ document.title }}</h2> <h2 class="mb-1">{{ document.title }}</h2>
{% if document.visibility == "private" %}
<span class="badge bg-dark" title="{% translate 'Private (Creator only)' %}"><i class="bi bi-lock-fill"></i> {% translate "Private" %}</span>
{% elif document.visibility == "restricted" %}
<span class="badge bg-warning text-dark" title="{% translate 'Shared with specific users' %}"><i class="bi bi-people-fill"></i> {% translate "Shared" %}</span>
{% elif document.visibility == "scoped" %}
<span class="badge bg-success" title="{% translate 'All qualified users (Internal & External)' %}"><i class="bi bi-globe"></i> {% translate "All qualified" %}</span>
{% else %}
<span class="badge bg-primary" title="{% translate 'Internal (Thematics & Contracts)' %}"><i class="bi bi-building"></i> {% translate "Internal" %}</span>
{% endif %}
{% if can_edit_document %} {% if can_edit_document %}
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" data-bs-toggle="modal" data-bs-target="#editDocumentModal" title="{% translate 'Modifier le titre et la description' %}"> <button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" data-bs-toggle="modal" data-bs-target="#editDocumentModal" title="{% translate 'Modifier le titre et la description' %}">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
@ -68,7 +77,11 @@
</li> </li>
{% for folder in breadcrumbs %} {% for folder in breadcrumbs %}
<li class="breadcrumb-item {% if forloop.last %}active{% endif %}"> <li class="breadcrumb-item {% if forloop.last %}active{% endif %}">
<a href="{% url 'documents:list' %}?folder={{ folder.slug }}">{{ folder.name }}</a> {% if folder.can_browse %}
<a href="{% url 'documents:list' %}?folder={{ folder.slug }}">{{ folder.name }}</a>
{% else %}
<span class="text-muted">{{ folder.name }}</span>
{% endif %}
</li> </li>
{% endfor %} {% endfor %}
<li class="breadcrumb-item active" aria-current="page">{{ document.title }}</li> <li class="breadcrumb-item active" aria-current="page">{{ document.title }}</li>
@ -732,6 +745,15 @@ document.addEventListener('DOMContentLoaded', function () {
<label for="id_doc_description" class="form-label fw-bold">{% translate "Description" %}</label> <label for="id_doc_description" class="form-label fw-bold">{% translate "Description" %}</label>
<textarea name="description" id="id_doc_description" class="form-control" rows="4" placeholder="{% translate 'Description facultative...' %}">{{ document.description }}</textarea> <textarea name="description" id="id_doc_description" class="form-control" rows="4" placeholder="{% translate 'Description facultative...' %}">{{ document.description }}</textarea>
</div> </div>
<div class="mb-3">
<label for="id_doc_visibility" class="form-label fw-bold">{% translate "Visibilité" %} <span class="text-danger">*</span></label>
<select name="visibility" id="id_doc_visibility" class="form-select">
<option value="private" {% if document.visibility == "private" %}selected{% endif %}>{% translate "Privé (Créateur uniquement)" %}</option>
<option value="restricted" {% if document.visibility == "restricted" %}selected{% endif %}>{% translate "Partagé avec certains utilisateurs" %}</option>
<option value="internal" {% if document.visibility == "internal" %}selected{% endif %}>{% translate "Interne (Thématiques & Contrats)" %}</option>
<option value="scoped" {% if document.visibility == "scoped" %}selected{% endif %}>{% translate "Tous les acteurs qualifiés (Internes & Externes)" %}</option>
</select>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button> <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>

View file

@ -26,6 +26,22 @@
<div class="text-danger small">{{ form.description.errors }}</div> <div class="text-danger small">{{ form.description.errors }}</div>
{% endif %} {% endif %}
</div> </div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="id_visibility">{% translate "Visibility" %}</label>
{{ form.visibility }}
{% if form.visibility.errors %}
<div class="text-danger small">{{ form.visibility.errors }}</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="id_thematics">{% translate "Thematics" %}</label>
{{ form.thematics }}
{% if form.thematics.errors %}
<div class="text-danger small">{{ form.thematics.errors }}</div>
{% endif %}
</div>
</div>
<div class="row"> <div class="row">
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label class="form-label" for="id_types">{% translate "Types" %}</label> <label class="form-label" for="id_types">{% translate "Types" %}</label>

View file

@ -28,8 +28,10 @@
<li class="breadcrumb-item {% if forloop.last %}active{% endif %}"> <li class="breadcrumb-item {% if forloop.last %}active{% endif %}">
{% if forloop.last %} {% if forloop.last %}
{{ folder.name }} {{ folder.name }}
{% else %} {% elif folder.can_browse %}
<a href="{% url 'documents:list' %}?folder={{ folder.slug }}{% if current_sort and current_sort != 'name' %}&amp;sort={{ current_sort }}{% endif %}">{{ folder.name }}</a> <a href="{% url 'documents:list' %}?folder={{ folder.slug }}{% if current_sort and current_sort != 'name' %}&amp;sort={{ current_sort }}{% endif %}">{{ folder.name }}</a>
{% else %}
<span class="text-muted">{{ folder.name }}</span>
{% endif %} {% endif %}
</li> </li>
{% endfor %} {% endfor %}
@ -110,6 +112,13 @@
<td> <td>
<i class="bi bi-folder {% if not folder.is_predefined %}text-warning{% endif %}"></i> <i class="bi bi-folder {% if not folder.is_predefined %}text-warning{% endif %}"></i>
<a href="{% url 'documents:list' %}?folder={{ folder.slug }}{% if current_sort and current_sort != 'name' %}&amp;sort={{ current_sort }}{% endif %}">{{ folder.name }}</a> <a href="{% url 'documents:list' %}?folder={{ folder.slug }}{% if current_sort and current_sort != 'name' %}&amp;sort={{ current_sort }}{% endif %}">{{ folder.name }}</a>
{% if folder.visibility == "private" %}
<span class="badge bg-dark ms-1" title="{% translate 'Privé (Créateur uniquement)' %}"><i class="bi bi-lock-fill"></i></span>
{% elif folder.visibility == "restricted" %}
<span class="badge bg-warning text-dark ms-1" title="{% translate 'Partagé avec certains utilisateurs' %}"><i class="bi bi-people-fill"></i></span>
{% elif folder.visibility == "scoped" %}
<span class="badge bg-success ms-1" title="{% translate 'Tous les acteurs qualifiés' %}"><i class="bi bi-globe"></i></span>
{% endif %}
</td> </td>
<td>{{ folder.updated_at|date:"SHORT_DATETIME_FORMAT" }}</td> <td>{{ folder.updated_at|date:"SHORT_DATETIME_FORMAT" }}</td>
<td>{{ item.updated_by|default:"—" }}</td> <td>{{ item.updated_by|default:"—" }}</td>
@ -180,6 +189,13 @@
<i class="bi bi-file-earmark"></i> <i class="bi bi-file-earmark"></i>
{% endif %} {% endif %}
<a href="{{ document.get_absolute_url }}">{{ document.title }}{% if document.get_file_extension %} ({{ document.get_file_extension }}){% endif %}</a> <a href="{{ document.get_absolute_url }}">{{ document.title }}{% if document.get_file_extension %} ({{ document.get_file_extension }}){% endif %}</a>
{% if document.visibility == "private" %}
<span class="badge bg-dark ms-1" title="{% translate 'Privé (Créateur uniquement)' %}"><i class="bi bi-lock-fill"></i></span>
{% elif document.visibility == "restricted" %}
<span class="badge bg-warning text-dark ms-1" title="{% translate 'Partagé avec certains utilisateurs' %}"><i class="bi bi-people-fill"></i></span>
{% elif document.visibility == "scoped" %}
<span class="badge bg-success ms-1" title="{% translate 'Tous les acteurs qualifiés' %}"><i class="bi bi-globe"></i></span>
{% endif %}
</td> </td>
<td>{{ document.updated_at|date:"SHORT_DATETIME_FORMAT" }}</td> <td>{{ document.updated_at|date:"SHORT_DATETIME_FORMAT" }}</td>
<td>{{ item.updated_by|default:"—" }}</td> <td>{{ item.updated_by|default:"—" }}</td>

View file

@ -82,8 +82,12 @@ class DocumentPermissionTests(TestCase):
) )
) )
def test_folder_share_grants_access(self): def test_folder_share_grants_folder_access_not_document_access(self):
self.assertTrue(user_has_document_permission(self.folder_user, self.document)) from .permissions import user_has_folder_permission
# Folder share grants access to the folder
self.assertTrue(user_has_folder_permission(self.folder_user, self.folder))
# But NOT to the document inside it (independence of folders and documents)
self.assertFalse(user_has_document_permission(self.folder_user, self.document))
def test_superuser_has_access(self): def test_superuser_has_access(self):
self.assertTrue(user_has_document_permission(self.superuser, self.document)) self.assertTrue(user_has_document_permission(self.superuser, self.document))
@ -98,7 +102,8 @@ class DocumentPermissionTests(TestCase):
other_ids = set(filter_documents_for_user(queryset, self.other_user).values_list("pk", flat=True)) other_ids = set(filter_documents_for_user(queryset, self.other_user).values_list("pk", flat=True))
self.assertEqual(shared_ids, {self.document.pk}) self.assertEqual(shared_ids, {self.document.pk})
self.assertEqual(folder_ids, {self.document.pk}) # Folder share user does not automatically get the document
self.assertEqual(folder_ids, set())
self.assertEqual(other_ids, set()) self.assertEqual(other_ids, set())
def test_filter_documents_for_superuser_returns_all(self): def test_filter_documents_for_superuser_returns_all(self):
@ -434,7 +439,7 @@ class DocumentDeletionPermissionTests(TestCase):
def test_internal_manager_can_delete_thematic_asset_document_owned_by_others(self): def test_internal_manager_can_delete_thematic_asset_document_owned_by_others(self):
from common.models import Thematic, UserThematics from common.models import Thematic, UserThematics
from assets.models import TrafficLightIntersection from assets.models import TrafficLightIntersection
from documents.models import DocumentFolderAttachment from documents.models import DocumentAttachment, DocumentFolderAttachment
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
thematic, _ = Thematic.objects.get_or_create( thematic, _ = Thematic.objects.get_or_create(
@ -448,22 +453,21 @@ class DocumentDeletionPermissionTests(TestCase):
sub_folder = DocumentFolder.objects.create(name="Subfolder Schemas", created_by=self.owner) sub_folder = DocumentFolder.objects.create(name="Subfolder Schemas", created_by=self.owner)
sub_folder.parent_folders.add(root_folder) sub_folder.parent_folders.add(root_folder)
DocumentFolderAttachment.objects.create( other_doc = ManagedDocument.objects.create(title="Other User Doc in Subfolder", created_by=self.owner)
folder=root_folder, other_doc.folders.add(sub_folder)
DocumentAttachment.objects.create(
document=other_doc,
content_type=ContentType.objects.get_for_model(TrafficLightIntersection), content_type=ContentType.objects.get_for_model(TrafficLightIntersection),
object_id=intersection.pk, object_id=intersection.pk,
) )
other_doc = ManagedDocument.objects.create(title="Other User Doc in Subfolder", created_by=self.owner) # The manager with edit rights on trafficlights should be allowed to delete the document
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) ensure_document_deletable(other_doc, self.manager)
def test_internal_manager_without_edit_rights_cannot_delete_thematic_asset_document_owned_by_others(self): def test_internal_manager_without_edit_rights_cannot_delete_thematic_asset_document_owned_by_others(self):
from common.models import Thematic, UserThematics from common.models import Thematic, UserThematics
from assets.models import TrafficLightIntersection from assets.models import TrafficLightIntersection
from documents.models import DocumentFolderAttachment from documents.models import DocumentAttachment, DocumentFolderAttachment
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
thematic, _ = Thematic.objects.get_or_create( thematic, _ = Thematic.objects.get_or_create(
@ -476,15 +480,14 @@ class DocumentDeletionPermissionTests(TestCase):
intersection = TrafficLightIntersection.objects.create(code="TL-TEST-2", name_fr="Carrefour Test 2") 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) root_folder = DocumentFolder.objects.create(name="Root Intersection Folder 2", created_by=self.owner)
DocumentFolderAttachment.objects.create( other_doc = ManagedDocument.objects.create(title="Other User Doc 2", created_by=self.owner)
folder=root_folder, other_doc.folders.add(root_folder)
DocumentAttachment.objects.create(
document=other_doc,
content_type=ContentType.objects.get_for_model(TrafficLightIntersection), content_type=ContentType.objects.get_for_model(TrafficLightIntersection),
object_id=intersection.pk, 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 # Deletion should raise PermissionDenied because manager lacks edit rights on the thematic
with self.assertRaises(PermissionDenied): with self.assertRaises(PermissionDenied):
ensure_document_deletable(other_doc, self.manager) ensure_document_deletable(other_doc, self.manager)
@ -492,7 +495,7 @@ class DocumentDeletionPermissionTests(TestCase):
def test_document_list_view_renders_delete_button_when_user_has_permission(self): def test_document_list_view_renders_delete_button_when_user_has_permission(self):
from common.models import Thematic, UserThematics from common.models import Thematic, UserThematics
from assets.models import TrafficLightIntersection from assets.models import TrafficLightIntersection
from documents.models import DocumentFolderAttachment from documents.models import DocumentAttachment, DocumentFolderAttachment
thematic, _ = Thematic.objects.get_or_create( thematic, _ = Thematic.objects.get_or_create(
code="trafficlights", code="trafficlights",
@ -502,7 +505,6 @@ class DocumentDeletionPermissionTests(TestCase):
intersection = TrafficLightIntersection.objects.create(code="TL-LIST-1", name_fr="Carrefour List 1") intersection = TrafficLightIntersection.objects.create(code="TL-LIST-1", name_fr="Carrefour List 1")
root_folder = DocumentFolder.objects.create(name="Root Intersection Folder List", created_by=self.owner) root_folder = DocumentFolder.objects.create(name="Root Intersection Folder List", created_by=self.owner)
DocumentFolderAttachment.objects.create( DocumentFolderAttachment.objects.create(
folder=root_folder, folder=root_folder,
content_type=ContentType.objects.get_for_model(TrafficLightIntersection), content_type=ContentType.objects.get_for_model(TrafficLightIntersection),
@ -511,6 +513,11 @@ class DocumentDeletionPermissionTests(TestCase):
other_doc = ManagedDocument.objects.create(title="Other User Doc List Test", created_by=self.owner) other_doc = ManagedDocument.objects.create(title="Other User Doc List Test", created_by=self.owner)
other_doc.folders.add(root_folder) other_doc.folders.add(root_folder)
DocumentAttachment.objects.create(
document=other_doc,
content_type=ContentType.objects.get_for_model(TrafficLightIntersection),
object_id=intersection.pk,
)
self.client.force_login(self.manager) self.client.force_login(self.manager)
response = self.client.get(reverse("documents:list"), {"folder": root_folder.slug}) response = self.client.get(reverse("documents:list"), {"folder": root_folder.slug})
@ -521,7 +528,7 @@ class DocumentDeletionPermissionTests(TestCase):
def test_document_list_view_hides_delete_button_when_user_lacks_permission(self): def test_document_list_view_hides_delete_button_when_user_lacks_permission(self):
from common.models import Thematic, UserThematics from common.models import Thematic, UserThematics
from assets.models import TrafficLightIntersection from assets.models import TrafficLightIntersection
from documents.models import DocumentFolderAttachment from documents.models import DocumentAttachment, DocumentFolderAttachment
thematic, _ = Thematic.objects.get_or_create( thematic, _ = Thematic.objects.get_or_create(
code="trafficlights", code="trafficlights",
@ -531,7 +538,6 @@ class DocumentDeletionPermissionTests(TestCase):
intersection = TrafficLightIntersection.objects.create(code="TL-LIST-2", name_fr="Carrefour List 2") intersection = TrafficLightIntersection.objects.create(code="TL-LIST-2", name_fr="Carrefour List 2")
root_folder = DocumentFolder.objects.create(name="Root Intersection Folder List 2", created_by=self.owner) root_folder = DocumentFolder.objects.create(name="Root Intersection Folder List 2", created_by=self.owner)
DocumentFolderAttachment.objects.create( DocumentFolderAttachment.objects.create(
folder=root_folder, folder=root_folder,
content_type=ContentType.objects.get_for_model(TrafficLightIntersection), content_type=ContentType.objects.get_for_model(TrafficLightIntersection),
@ -540,6 +546,11 @@ class DocumentDeletionPermissionTests(TestCase):
other_doc = ManagedDocument.objects.create(title="Other User Doc List Test 2", created_by=self.owner) other_doc = ManagedDocument.objects.create(title="Other User Doc List Test 2", created_by=self.owner)
other_doc.folders.add(root_folder) other_doc.folders.add(root_folder)
DocumentAttachment.objects.create(
document=other_doc,
content_type=ContentType.objects.get_for_model(TrafficLightIntersection),
object_id=intersection.pk,
)
self.client.force_login(self.manager) self.client.force_login(self.manager)
response = self.client.get(reverse("documents:list"), {"folder": root_folder.slug}) response = self.client.get(reverse("documents:list"), {"folder": root_folder.slug})
@ -856,4 +867,144 @@ class DocumentUpdateViewTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "editDocumentModal") self.assertContains(response, "editDocumentModal")
self.assertContains(response, 'value="Original Title"') self.assertContains(response, 'value="Original Title"')
class DocumentVisibilityAndIndependenceTests(TestCase):
def setUp(self):
from common.models import Thematic, UserThematics
from .models import VisibilityScope
from .permissions import user_has_document_permission, user_has_folder_permission, user_can_browse_folder
self.thematic = Thematic.objects.create(code="roads", name_fr="Voirie", name_nl="Wegen")
# Internal user with thematic
self.internal_user = User.objects.create_user(username="intern_user", password="pwd")
self.internal_config = UserConfig.objects.create(user=self.internal_user, is_intern=True)
UserThematics.objects.create(user_config=self.internal_config, thematic=self.thematic, can_view_assets=True)
# External user with thematic
self.external_user = User.objects.create_user(username="extern_user", password="pwd")
self.external_config = UserConfig.objects.create(user=self.external_user, is_intern=False)
UserThematics.objects.create(user_config=self.external_config, thematic=self.thematic, can_view_assets=True)
# User without thematic
self.other_user = User.objects.create_user(username="other_unrelated", password="pwd")
UserConfig.objects.create(user=self.other_user, is_intern=True)
self.creator = User.objects.create_user(username="creator_user", password="pwd")
UserConfig.objects.create(user=self.creator, is_intern=True)
def test_visibility_private(self):
from .models import VisibilityScope
from .permissions import user_has_document_permission, filter_documents_for_user
doc = ManagedDocument.objects.create(
title="Private Doc",
created_by=self.creator,
visibility=VisibilityScope.PRIVATE,
)
doc.thematics.add(self.thematic)
# Only creator can view
self.assertTrue(user_has_document_permission(self.creator, doc))
self.assertFalse(user_has_document_permission(self.internal_user, doc))
self.assertFalse(user_has_document_permission(self.external_user, doc))
self.assertFalse(user_has_document_permission(self.other_user, doc))
def test_visibility_restricted(self):
from .models import VisibilityScope
from .permissions import user_has_document_permission
doc = ManagedDocument.objects.create(
title="Restricted Doc",
created_by=self.creator,
visibility=VisibilityScope.RESTRICTED,
)
doc.thematics.add(self.thematic)
# Before sharing: only creator
self.assertTrue(user_has_document_permission(self.creator, doc))
self.assertFalse(user_has_document_permission(self.external_user, doc))
# Share with external user
DocumentShare.objects.create(document=doc, user=self.external_user, permission=ManagedDocument.PERMISSION_VIEW)
self.assertTrue(user_has_document_permission(self.external_user, doc))
self.assertFalse(user_has_document_permission(self.internal_user, doc))
def test_visibility_internal(self):
from .models import VisibilityScope
from .permissions import user_has_document_permission
doc = ManagedDocument.objects.create(
title="Internal Doc",
created_by=self.creator,
visibility=VisibilityScope.INTERNAL,
)
doc.thematics.add(self.thematic)
# Internal with thematic has access
self.assertTrue(user_has_document_permission(self.internal_user, doc))
# External with thematic has NO access to internal
self.assertFalse(user_has_document_permission(self.external_user, doc))
# Internal without thematic has NO access
self.assertFalse(user_has_document_permission(self.other_user, doc))
def test_visibility_scoped(self):
from .models import VisibilityScope
from .permissions import user_has_document_permission
doc = ManagedDocument.objects.create(
title="Scoped Doc",
created_by=self.creator,
visibility=VisibilityScope.SCOPED,
)
doc.thematics.add(self.thematic)
# Both internal and external with thematic have access
self.assertTrue(user_has_document_permission(self.internal_user, doc))
self.assertTrue(user_has_document_permission(self.external_user, doc))
# User without thematic has NO access
self.assertFalse(user_has_document_permission(self.other_user, doc))
def test_folder_and_document_independence(self):
"""
External user has access to document D (scoped) in folder R (internal).
User can access document D, but cannot browse folder R.
"""
from .models import VisibilityScope
from .permissions import user_has_document_permission, user_can_browse_folder
folder = DocumentFolder.objects.create(
name="Internal Folder R",
slug="folder-r",
created_by=self.creator,
visibility=VisibilityScope.INTERNAL,
)
folder.thematics.add(self.thematic)
doc = ManagedDocument.objects.create(
title="Scoped Document D",
created_by=self.creator,
visibility=VisibilityScope.SCOPED,
)
doc.thematics.add(self.thematic)
doc.folders.add(folder)
# External user can access document D
self.assertTrue(user_has_document_permission(self.external_user, doc))
# External user CANNOT browse internal folder R
self.assertFalse(user_can_browse_folder(self.external_user, folder))
# Test UI Detail View breadcrumb: folder R should not be clickable for external user
self.client.force_login(self.external_user)
response = self.client.get(doc.get_absolute_url())
self.assertEqual(response.status_code, 200)
# Breadcrumb should display folder name as plain text (not as a link)
self.assertContains(response, f'<span class="text-muted">{folder.name}</span>')
self.assertNotContains(response, f'?folder={folder.slug}">')
# When external user attempts to open folder R in list view -> 404
folder_response = self.client.get(reverse("documents:list"), {"folder": folder.slug})
self.assertEqual(folder_response.status_code, 404)

View file

@ -46,10 +46,12 @@ from .permissions import (
filter_documents_for_user, filter_documents_for_user,
filter_folders_for_user, filter_folders_for_user,
get_user_visible_folder_ids, get_user_visible_folder_ids,
user_can_browse_folder,
user_can_delete_document, user_can_delete_document,
user_is_internal_manager,
user_has_document_permission, user_has_document_permission,
user_has_folder_permission,
user_is_documents_admin, user_is_documents_admin,
user_is_internal_manager,
) )
@ -76,15 +78,18 @@ class DocumentsAppAccessMixin:
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
# Les utilisateurs internes (managers, controllers, etc.) ont accès # Les utilisateurs internes (managers, controllers, etc.) ont accès
# Le filtrage des dossiers/documents est assuré par filter_folders_for_user
if config.is_intern: if config.is_intern:
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
# Les utilisateurs externes qui ont accès à au moins un document ou dossier
if filter_documents_for_user(ManagedDocument.objects.all(), request.user).exists() or filter_folders_for_user(DocumentFolder.objects.all(), request.user).exists():
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied(_("You don't have access to the document management module.")) raise PermissionDenied(_("You don't have access to the document management module."))
def get_folder_navigation_context(user, current_folder=None): def get_folder_navigation_context(user, current_folder=None):
breadcrumbs = get_folder_breadcrumb(current_folder) breadcrumbs = get_folder_breadcrumb(current_folder, user=user)
return { return {
"root_folders": get_root_folders(user), "root_folders": get_root_folders(user),
"folder_tree": build_folder_tree(user, current_folder=current_folder), "folder_tree": build_folder_tree(user, current_folder=current_folder),
@ -127,7 +132,7 @@ def get_descendant_folder_ids(folder, visible_ids=None):
return descendant_ids return descendant_ids
def get_folder_breadcrumb(folder): def get_folder_breadcrumb(folder, user=None):
if folder is None: if folder is None:
return [] return []
@ -136,6 +141,10 @@ def get_folder_breadcrumb(folder):
current = folder current = folder
while current and current.pk not in visited: while current and current.pk not in visited:
if user is not None:
current.can_browse = user_can_browse_folder(user, current)
else:
current.can_browse = True
breadcrumb.append(current) breadcrumb.append(current)
visited.add(current.pk) visited.add(current.pk)
parents = list(current.parent_folders.all()) parents = list(current.parent_folders.all())
@ -303,7 +312,7 @@ class DocumentListView(DocumentsAppAccessMixin, LoginRequiredMixin, ListView):
self.request.user, getattr(self, "current_folder", None) self.request.user, getattr(self, "current_folder", None)
) )
) )
context.setdefault("breadcrumbs", get_folder_breadcrumb(getattr(self, "current_folder", None))) context.setdefault("breadcrumbs", get_folder_breadcrumb(getattr(self, "current_folder", None), user=self.request.user))
folder_slug = self.request.GET.get("folder") folder_slug = self.request.GET.get("folder")
current_folder = getattr(self, "current_folder", None) current_folder = getattr(self, "current_folder", None)
@ -367,7 +376,7 @@ class DocumentListView(DocumentsAppAccessMixin, LoginRequiredMixin, ListView):
context["visible_folders"] = visible_folders context["visible_folders"] = visible_folders
context["subfolders"] = visible_folders context["subfolders"] = visible_folders
breadcrumbs = get_folder_breadcrumb(current_folder) breadcrumbs = get_folder_breadcrumb(current_folder, user=self.request.user)
context["breadcrumbs"] = breadcrumbs context["breadcrumbs"] = breadcrumbs
active_folder_slug = current_folder.slug if current_folder else None active_folder_slug = current_folder.slug if current_folder else None
@ -972,7 +981,7 @@ class DocumentDetailView(DocumentsAppAccessMixin, LoginRequiredMixin, DetailView
active_folder_slug = navigation_folder.slug if navigation_folder else None active_folder_slug = navigation_folder.slug if navigation_folder else None
context["active_folder_slug"] = active_folder_slug context["active_folder_slug"] = active_folder_slug
breadcrumbs = get_folder_breadcrumb(navigation_folder) breadcrumbs = get_folder_breadcrumb(navigation_folder, user=self.request.user)
context["breadcrumbs"] = breadcrumbs context["breadcrumbs"] = breadcrumbs
context["active_trail_slugs"] = [folder.slug for folder in breadcrumbs] context["active_trail_slugs"] = [folder.slug for folder in breadcrumbs]
context["document_breadcrumb"] = breadcrumbs + ([document] if document else []) context["document_breadcrumb"] = breadcrumbs + ([document] if document else [])