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",
created_by=self.superuser
)
doc.thematics.add(self.thematic_documents)
doc.folders.add(te_folder)
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 Meta:
model = ManagedDocument
fields = ["title", "description"]
fields = ["title", "description", "visibility", "thematics"]
widgets = {
"title": forms.TextInput(attrs={"class": "form-control"}),
"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):
super().__init__(*args, **kwargs)
self.fields["title"].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):
@ -53,9 +60,11 @@ class ManagedDocumentForm(forms.ModelForm):
class Meta:
model = ManagedDocument
fields = ["title", "description", "types", "tags", "folders"]
fields = ["title", "description", "visibility", "thematics", "types", "tags", "folders"]
widgets = {
"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"}),
"tags": 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)
initial_folder = kwargs.pop("initial_folder", None)
super().__init__(*args, **kwargs)
self.fields["visibility"].required = False
# Limiter les folders accessibles à l'utilisateur
if user:
@ -335,6 +345,7 @@ class DocumentFolderForm(forms.ModelForm):
fields = [
"name",
"description",
"visibility",
"application_label",
"parent_folders",
"thematics",
@ -343,6 +354,7 @@ class DocumentFolderForm(forms.ModelForm):
widgets = {
"name": forms.TextInput(attrs={"class": "form-control"}),
"description": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
"visibility": forms.Select(attrs={"class": "form-select"}),
"application_label": forms.TextInput(attrs={"class": "form-control"}),
"parent_folders": 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):
self.request_user = kwargs.pop("user", None)
super().__init__(*args, **kwargs)
if "visibility" in self.fields:
self.fields["visibility"].required = False
instance = getattr(self, "instance", None)
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)
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):
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
name = models.CharField(max_length=255, verbose_name=_("Name"))
slug = models.SlugField(max_length=150, unique=True, verbose_name=_("Slug"))
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"))
application_label = models.CharField(
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)
title = models.CharField(max_length=255, verbose_name=_("Title"))
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(
User,
on_delete=models.SET_NULL,
@ -221,6 +242,12 @@ class ManagedDocument(models.Model):
types = models.ManyToManyField(DocumentType, 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(
DocumentFolder,
related_name="documents",

View file

@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Iterable
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.db import models
@ -11,9 +12,10 @@ from django.db import models
from .models import (
DocumentFolder,
DocumentFolderAttachment,
DocumentAttachment,
ManagedDocument,
VisibilityScope,
)
from django.conf import settings
UserModel = get_user_model()
@ -35,7 +37,6 @@ def _normalise_permission(permission: str) -> str:
def _permissions_at_least(permission: str) -> Iterable[str]:
"""Return every permission value equal or greater than ``permission``."""
level = _PERMISSION_ORDER[_normalise_permission(permission)]
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,
) -> 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):
return queryset.none()
if getattr(user, "is_superuser", False):
if getattr(user, "is_superuser", False) or user_is_documents_admin(user):
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))
is_edit_check = _PERMISSION_ORDER.get(required_permission, 0) >= _PERMISSION_ORDER[ManagedDocument.PERMISSION_EDIT]
filter_q = models.Q(created_by=user)
filter_q |= models.Q(shares__user=user, shares__permission__in=allowed_permissions)
filter_q |= models.Q(folders__shares__user=user, folders__shares__permission__in=allowed_permissions)
# 1. Accès direct : créateur ou partage direct (DocumentShare)
base_q = models.Q(created_by=user)
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)
accessible_thematics = _get_accessible_thematic_ids(user)
# 2. Documents qualifiés par thématiques & contrats (attachments et direct thematics)
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:
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
accessible_folder_ids = _get_folder_ids_from_accessible_objects(user)
if accessible_folder_ids:
filter_q |= models.Q(folders__pk__in=accessible_folder_ids)
qualif_doc_ids = attached_doc_ids | thematic_doc_ids
# Ajouter les documents directement attachés via DocumentAttachment à un objet accessible
# (couvre les documents importés sans folder ou dont le folder n'a pas de DocumentFolderAttachment)
accessible_ct_ids = _get_accessible_content_type_ids(user)
if accessible_ct_ids:
filter_q |= models.Q(attachments__content_type_id__in=accessible_ct_ids)
if qualif_doc_ids:
internal_q = (
models.Q(visibility=VisibilityScope.INTERNAL, pk__in=qualif_doc_ids)
if is_intern
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()
@ -117,61 +130,105 @@ def user_has_document_permission(
if user is None or not getattr(user, "is_authenticated", False):
return False
if getattr(user, "is_superuser", False):
if getattr(user, "is_superuser", False) or user_is_documents_admin(user):
return True
from assets.permissions import can_view_exceptional_transport
if not can_view_exceptional_transport(user):
if (
document.tags.filter(slug__in=['exceptional_transport', 'exceptional-transport', 'transports-exceptionnels']).exists()
or document.folders.filter(
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()
):
if document.tags.filter(slug__in=['exceptional_transport', 'exceptional-transport', 'transports-exceptionnels']).exists():
return False
if any(_is_exceptional_transport_folder(f) for f in document.folders.all()):
return False
if document.created_by_id and document.created_by_id == getattr(user, "pk", None):
return True
allowed_permissions = list(_permissions_at_least(required_permission))
if document.shares.filter(user=user, permission__in=allowed_permissions).exists():
return True
if document.folders.filter(
shares__user=user, shares__permission__in=allowed_permissions
).exists():
# PRIVATE et RESTRICTED ne sont accessibles que via créateur ou partage direct
if document.visibility in [VisibilityScope.PRIVATE, VisibilityScope.RESTRICTED]:
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
# Pour les vérifications EDIT+, on exige can_edit_assets=True sur la thématique.
# Pour VIEW/COMMENT, can_view_assets=True suffit.
from assets.permissions import can_view_exceptional_transport
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]
# 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)
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
# 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)
if accessible_folder_ids and all_doc_folder_ids:
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():
for attachment in folder.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
@ -185,8 +242,7 @@ def user_is_documents_admin(user) -> bool:
config = getattr(user, "config", None)
if config is None:
return False
# Seuls les admins ont tous les droits d'administration sur les répertoires et documents
if config.roles.filter(name="admin").exists():
if config.roles.filter(name__in=["admin", "operator"]).exists():
return True
return False
@ -214,8 +270,6 @@ def user_can_delete_document(
return user_has_document_permission(user, document, required_permission=ManagedDocument.PERMISSION_EDIT)
def _get_accessible_thematic_ids(user) -> set[int]:
config = getattr(user, "config", None)
if not config:
@ -255,7 +309,6 @@ def _folder_contract_ids(folder_id: int) -> set[int]:
)
contract_ids: set[int] = set()
# Grouper les attachments par content_type pour optimiser les requêtes
from collections import defaultdict
attachments_by_type = defaultdict(list)
@ -266,7 +319,6 @@ def _folder_contract_ids(folder_id: int) -> set[int]:
else:
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
for content_type_id, object_ids in attachments_by_type.items():
try:
@ -275,17 +327,54 @@ def _folder_contract_ids(folder_id: int) -> set[int]:
if model_class is None:
continue
# Récupérer tous les objets d'un coup avec leurs contrats
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'):
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: # pragma: no cover - defensive
except Exception:
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
@ -297,14 +386,10 @@ def _filter_ids_by_contracts(folder_ids: set[int], user) -> set[int]:
return folder_ids
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 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(
folder_id__in=folder_ids
).select_related('content_type').values(
@ -315,7 +400,6 @@ def _filter_ids_by_contracts(folder_ids: set[int], user) -> set[int]:
folder_contracts = defaultdict(set)
attachments_by_type = defaultdict(list)
# Première passe: identifier les contrats directs et grouper les autres par type
for att in attachments:
folder_id = att['folder_id']
if att['content_type__app_label'] == 'contracts' and att['content_type__model'] == 'contract':
@ -326,7 +410,6 @@ def _filter_ids_by_contracts(folder_ids: set[int], user) -> set[int]:
'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():
try:
content_type = ContentType.objects.get(pk=ct_id)
@ -336,7 +419,6 @@ def _filter_ids_by_contracts(folder_ids: set[int], user) -> set[int]:
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'):
objects_with_contracts = model_class.objects.filter(
pk__in=object_ids
@ -350,26 +432,94 @@ def _filter_ids_by_contracts(folder_ids: set[int], user) -> set[int]:
else:
obj_to_contract = {}
# Associer les contrats aux folders
for att in att_list:
contract_id = obj_to_contract.get(att['object_id'])
if contract_id:
folder_contracts[att['folder_id']].add(contract_id)
except Exception: # pragma: no cover - defensive
except Exception:
continue
# Filtrer les folders qui ont au moins un contrat autorisé
allowed: set[int] = set()
for folder_id in folder_ids:
contract_ids = folder_contracts.get(folder_id, set())
if not contract_ids: # Pas de contrats = accessible
if not contract_ids:
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)
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
# Mapping centralisé des modèles par thématique (utilisé dans plusieurs fonctions)
_THEMATIC_MODEL_MAPPING: dict[str, list[str]] = {
'trafficlights': [
@ -412,16 +562,9 @@ _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.).
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:
return False
@ -430,11 +573,9 @@ def _user_can_access_content_object(user, content_object) -> bool:
if not config:
return False
# Les admins et operators peuvent tout voir
if config.roles.filter(name__in=['admin', 'operator']).exists():
return True
# Déterminer la thématique de l'objet
from django.contrib.contenttypes.models import ContentType
from common.models import Thematic, UserThematics
@ -443,27 +584,38 @@ def _user_can_access_content_object(user, content_object) -> bool:
thematic_code = _MODEL_TO_THEMATIC.get(model_name)
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
# Vérifier si l'utilisateur a accès à la thématique
thematic = Thematic.objects.filter(code=thematic_code).first()
if not thematic:
return False
has_access = UserThematics.objects.filter(
user_config=config,
thematic=thematic,
can_view_assets=True
).exists()
filter_kwargs = {'user_config': config, 'thematic': thematic, 'can_edit_assets': True} if edit_only else {'user_config': config, 'thematic': thematic, 'can_view_assets': True}
has_access = UserThematics.objects.filter(**filter_kwargs).exists()
if not has_access:
return False
# Vérifier les restrictions de contrat si applicable
if config.limit_assets_to_contracts:
allowed_contract_ids = _get_user_contract_ids(user)
if allowed_contract_ids:
# Vérifier si l'objet est lié à un contrat autorisé
contract_id = getattr(content_object, "contract_id", None)
if contract_id and contract_id not in allowed_contract_ids:
return False
@ -476,26 +628,15 @@ def _user_can_access_content_object(user, content_object) -> bool:
return True
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)
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)
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)}'
if hasattr(user, '_folder_cache') and cache_key in user._folder_cache:
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
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]:
"""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)}'
if hasattr(user, '_folder_cache') and cache_key in user._folder_cache:
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()
config = getattr(user, "config", None)
if not config:
return accessible_folder_ids
if user_is_documents_admin(user):
if not config or user_is_documents_admin(user):
return accessible_folder_ids
accessible_content_types = _get_editable_content_type_ids(user) if edit_only else _get_accessible_content_type_ids(user)
if not 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
if accessible_content_types:
attachments = DocumentFolderAttachment.objects.filter(
content_type_id__in=accessible_content_types
).values_list('folder_id', flat=True).distinct()
accessible_folder_ids = set(attachments)
# Filtrer par contrats si nécessaire
if config.limit_assets_to_contracts:
allowed_contract_ids = _get_user_contract_ids(user)
if allowed_contract_ids:
accessible_folder_ids = _filter_ids_by_contracts(accessible_folder_ids, user)
# ── 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.
# Folders de projets
if not edit_only:
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
try:
project_ct = ContentType.objects.get(app_label='projects', model='project')
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
# 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'):
user._folder_cache = {}
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
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]:
if not folder_ids:
return set()
@ -635,12 +818,9 @@ def _collect_ancestor_ids(folder_ids: set[int]) -> set[int]:
seen = set(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 = {}
if stack:
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(
from_documentfolder_id__in=folder_ids
).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].append(parent_id)
# Parcours itératif en utilisant les relations pré-chargées
while stack:
current_id = stack.pop()
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]
if new_parents:
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):
return queryset.none()
if user_is_documents_admin(user):
if getattr(user, "is_superuser", False) or user_is_documents_admin(user):
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))
is_edit_check = _PERMISSION_ORDER.get(required_permission, 0) >= _PERMISSION_ORDER[ManagedDocument.PERMISSION_EDIT]
owned_ids = set(
queryset.filter(created_by=user).values_list("pk", flat=True)
)
shared_ids = set(
queryset.filter(
shares__user=user, shares__permission__in=allowed_permissions
).values_list("pk", flat=True)
)
# 1. Base : créateur ou partage direct (DocumentFolderShare)
base_q = models.Q(created_by=user)
base_q |= models.Q(shares__user=user, shares__permission__in=allowed_permissions)
thematic_ids = set()
accessible_thematics = _get_accessible_thematic_ids(user)
# 2. Scope qualifié : thématiques directes ou objets attachés (DocumentFolderAttachment)
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:
thematic_ids = set(
queryset.filter(thematics__pk__in=accessible_thematics).values_list(
"pk", flat=True
)
thematic_folder_ids = set(
queryset.filter(thematics__pk__in=accessible_thematics).values_list("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)
attached_object_ids = _get_folder_ids_from_accessible_objects(user)
if qualif_folder_ids:
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
if include_ancestors:
visible_ids = set(queryset.filter(filter_q).values_list("pk", flat=True))
if include_ancestors and visible_ids:
visible_ids = _collect_ancestor_ids(visible_ids)
if not visible_ids:
@ -763,71 +948,26 @@ def filter_folders_for_user(
def user_can_browse_folder(user, folder: DocumentFolder) -> bool:
"""
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):
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):
return True
# Les utilisateurs internes (managers, controllers, etc.) ont accès
# 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
return user_has_folder_permission(user, folder, required_permission=ManagedDocument.PERMISSION_VIEW)
def get_user_visible_folder_ids(user) -> set[int]:
"""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'
if hasattr(user, '_folder_cache') and cache_key in user._folder_cache:
return user._folder_cache[cache_key]
queryset = DocumentFolder.objects.all()
visible_qs = filter_folders_for_user(
queryset, user, include_ancestors=True
)
visible_qs = filter_folders_for_user(queryset, user, include_ancestors=True)
visible_ids = set(visible_qs.values_list("pk", flat=True))
# Mettre en cache
if not hasattr(user, '_folder_cache'):
user._folder_cache = {}
user._folder_cache[cache_key] = visible_ids
@ -841,5 +981,8 @@ __all__ = [
"get_user_visible_folder_ids",
"user_is_documents_admin",
"user_has_document_permission",
"user_has_folder_permission",
"user_can_browse_folder",
"user_can_delete_document",
"user_is_internal_manager",
]

View file

@ -21,6 +21,15 @@
<div>
<div class="d-flex align-items-center gap-2">
<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 %}
<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>
@ -68,7 +77,11 @@
</li>
{% for folder in breadcrumbs %}
<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>
{% endfor %}
<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>
<textarea name="description" id="id_doc_description" class="form-control" rows="4" placeholder="{% translate 'Description facultative...' %}">{{ document.description }}</textarea>
</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 class="modal-footer">
<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>
{% endif %}
</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="col-md-6 mb-3">
<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 %}">
{% if forloop.last %}
{{ 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>
{% else %}
<span class="text-muted">{{ folder.name }}</span>
{% endif %}
</li>
{% endfor %}
@ -110,6 +112,13 @@
<td>
<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>
{% 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>{{ folder.updated_at|date:"SHORT_DATETIME_FORMAT" }}</td>
<td>{{ item.updated_by|default:"—" }}</td>
@ -180,6 +189,13 @@
<i class="bi bi-file-earmark"></i>
{% endif %}
<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>{{ document.updated_at|date:"SHORT_DATETIME_FORMAT" }}</td>
<td>{{ item.updated_by|default:"—" }}</td>

View file

@ -82,8 +82,12 @@ class DocumentPermissionTests(TestCase):
)
)
def test_folder_share_grants_access(self):
self.assertTrue(user_has_document_permission(self.folder_user, self.document))
def test_folder_share_grants_folder_access_not_document_access(self):
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):
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))
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())
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):
from common.models import Thematic, UserThematics
from assets.models import TrafficLightIntersection
from documents.models import DocumentFolderAttachment
from documents.models import DocumentAttachment, DocumentFolderAttachment
from django.core.exceptions import PermissionDenied
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.parent_folders.add(root_folder)
DocumentFolderAttachment.objects.create(
folder=root_folder,
other_doc = ManagedDocument.objects.create(title="Other User Doc in Subfolder", created_by=self.owner)
other_doc.folders.add(sub_folder)
DocumentAttachment.objects.create(
document=other_doc,
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
# The manager with edit rights on trafficlights should be allowed to delete the document
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 documents.models import DocumentAttachment, DocumentFolderAttachment
from django.core.exceptions import PermissionDenied
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")
root_folder = DocumentFolder.objects.create(name="Root Intersection Folder 2", created_by=self.owner)
DocumentFolderAttachment.objects.create(
folder=root_folder,
other_doc = ManagedDocument.objects.create(title="Other User Doc 2", created_by=self.owner)
other_doc.folders.add(root_folder)
DocumentAttachment.objects.create(
document=other_doc,
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)
@ -492,7 +495,7 @@ class DocumentDeletionPermissionTests(TestCase):
def test_document_list_view_renders_delete_button_when_user_has_permission(self):
from common.models import Thematic, UserThematics
from assets.models import TrafficLightIntersection
from documents.models import DocumentFolderAttachment
from documents.models import DocumentAttachment, DocumentFolderAttachment
thematic, _ = Thematic.objects.get_or_create(
code="trafficlights",
@ -502,7 +505,6 @@ class DocumentDeletionPermissionTests(TestCase):
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)
DocumentFolderAttachment.objects.create(
folder=root_folder,
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.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)
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):
from common.models import Thematic, UserThematics
from assets.models import TrafficLightIntersection
from documents.models import DocumentFolderAttachment
from documents.models import DocumentAttachment, DocumentFolderAttachment
thematic, _ = Thematic.objects.get_or_create(
code="trafficlights",
@ -531,7 +538,6 @@ class DocumentDeletionPermissionTests(TestCase):
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)
DocumentFolderAttachment.objects.create(
folder=root_folder,
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.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)
response = self.client.get(reverse("documents:list"), {"folder": root_folder.slug})
@ -857,3 +868,143 @@ class DocumentUpdateViewTests(TestCase):
self.assertContains(response, "editDocumentModal")
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_folders_for_user,
get_user_visible_folder_ids,
user_can_browse_folder,
user_can_delete_document,
user_is_internal_manager,
user_has_document_permission,
user_has_folder_permission,
user_is_documents_admin,
user_is_internal_manager,
)
@ -76,15 +78,18 @@ class DocumentsAppAccessMixin:
return super().dispatch(request, *args, **kwargs)
# 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:
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."))
def get_folder_navigation_context(user, current_folder=None):
breadcrumbs = get_folder_breadcrumb(current_folder)
breadcrumbs = get_folder_breadcrumb(current_folder, user=user)
return {
"root_folders": get_root_folders(user),
"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
def get_folder_breadcrumb(folder):
def get_folder_breadcrumb(folder, user=None):
if folder is None:
return []
@ -136,6 +141,10 @@ def get_folder_breadcrumb(folder):
current = folder
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)
visited.add(current.pk)
parents = list(current.parent_folders.all())
@ -303,7 +312,7 @@ class DocumentListView(DocumentsAppAccessMixin, LoginRequiredMixin, ListView):
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")
current_folder = getattr(self, "current_folder", None)
@ -367,7 +376,7 @@ class DocumentListView(DocumentsAppAccessMixin, LoginRequiredMixin, ListView):
context["visible_folders"] = 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
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
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["active_trail_slugs"] = [folder.slug for folder in breadcrumbs]
context["document_breadcrumb"] = breadcrumbs + ([document] if document else [])