feat: implement document and folder visibility levels with thematic filtering support
This commit is contained in:
parent
577fb1232b
commit
22d0ad9928
10 changed files with 699 additions and 271 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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' %}&sort={{ current_sort }}{% endif %}">{{ folder.name }}</a>
|
<a href="{% url 'documents:list' %}?folder={{ folder.slug }}{% if current_sort and current_sort != 'name' %}&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' %}&sort={{ current_sort }}{% endif %}">{{ folder.name }}</a>
|
<a href="{% url 'documents:list' %}?folder={{ folder.slug }}{% if current_sort and current_sort != 'name' %}&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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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 [])
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue