feat: implement document management service with recursive versioning and folder navigation logic

This commit is contained in:
Karl Determe 2026-09-01 12:58:09 +02:00
parent 913c00e34b
commit ee90cf3173
9 changed files with 353 additions and 46 deletions

View file

@ -67,6 +67,13 @@ def filter_documents_for_user(
# 2. Documents qualifiés par thématiques & contrats (attachments et direct thematics) # 2. Documents qualifiés par thématiques & contrats (attachments et direct thematics)
attached_doc_ids = _get_document_ids_from_objects(user, edit_only=is_edit_check) attached_doc_ids = _get_document_ids_from_objects(user, edit_only=is_edit_check)
attached_folder_ids = _get_folder_ids_from_objects(user, edit_only=is_edit_check)
if attached_folder_ids:
descendant_folder_ids = _collect_descendant_ids(attached_folder_ids)
folder_doc_ids = set(
queryset.filter(folders__pk__in=descendant_folder_ids).values_list("pk", flat=True)
)
attached_doc_ids |= folder_doc_ids
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)
thematic_doc_ids: set[int] = set() thematic_doc_ids: set[int] = set()
@ -177,6 +184,15 @@ def user_has_document_permission(
if content_object and _user_can_access_content_object(user, content_object, edit_only=is_edit_check): if content_object and _user_can_access_content_object(user, content_object, edit_only=is_edit_check):
return True return True
# 3. Vérification dossiers attachés à des objets accessibles
doc_folders = list(document.folders.all())
if doc_folders:
doc_folder_ids = {f.pk for f in doc_folders}
all_folder_ids = _collect_ancestor_ids(doc_folder_ids)
accessible_folder_ids = _get_folder_ids_from_objects(user, edit_only=is_edit_check)
if all_folder_ids & accessible_folder_ids:
return True
return False return False
@ -231,6 +247,13 @@ def user_has_folder_permission(
if content_object and _user_can_access_content_object(user, content_object, edit_only=is_edit_check): if content_object and _user_can_access_content_object(user, content_object, edit_only=is_edit_check):
return True return True
# Sous-dossier d'un dossier attaché à un objet accessible
all_ancestor_ids = _collect_ancestor_ids({folder.pk}) - {folder.pk}
if all_ancestor_ids:
accessible_folder_ids = _get_folder_ids_from_objects(user, edit_only=is_edit_check)
if all_ancestor_ids & accessible_folder_ids:
return True
return False return False
@ -605,14 +628,10 @@ def _user_can_access_content_object(user, content_object, edit_only: bool = Fals
if not thematic_code: if not thematic_code:
# Cas des projets ou contrats ou autres modèles génériques # Cas des projets ou contrats ou autres modèles génériques
if content_type.app_label == 'projects' and model_name == 'project': if content_type.app_label == 'projects' and model_name == 'project':
from projects.models import ProjectUserAccess from projects.permissions import can_view_project, can_add_or_edit_project
if ProjectUserAccess.objects.filter(user=user, project=content_object, can_view=True).exists(): if edit_only:
return True return can_add_or_edit_project(user, content_object)
if config.is_intern: return can_view_project(user, content_object)
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 content_type.app_label == 'contracts' and model_name == 'contract':
if not config.limit_assets_to_contracts: if not config.limit_assets_to_contracts:
@ -725,25 +744,11 @@ def _get_folder_ids_from_objects(user, *, edit_only: bool) -> set[int]:
project_ct = None project_ct = None
if project_ct: if project_ct:
from projects.models import Project, ProjectUserAccess from projects.permissions import filter_viewable_projects_for_user
from common.models import UserThematics
thematic_project_ids: set[int] = set() accessible_project_ids = set(
accessible_thematics = UserThematics.objects.filter( filter_viewable_projects_for_user(user).values_list('pk', flat=True)
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: if accessible_project_ids:
project_folder_ids = set( project_folder_ids = set(
DocumentFolderAttachment.objects.filter( DocumentFolderAttachment.objects.filter(
@ -795,25 +800,11 @@ def _get_document_ids_from_objects(user, *, edit_only: bool) -> set[int]:
project_ct = None project_ct = None
if project_ct: if project_ct:
from projects.models import Project, ProjectUserAccess from projects.permissions import filter_viewable_projects_for_user
from common.models import UserThematics
thematic_project_ids: set[int] = set() accessible_project_ids = set(
accessible_thematics = UserThematics.objects.filter( filter_viewable_projects_for_user(user).values_list('pk', flat=True)
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: if accessible_project_ids:
project_doc_ids = set( project_doc_ids = set(
DocumentAttachment.objects.filter( DocumentAttachment.objects.filter(
@ -920,6 +911,10 @@ def filter_folders_for_user(
# 2. Scope qualifié : thématiques directes ou objets attachés (DocumentFolderAttachment) # 2. Scope qualifié : thématiques directes ou objets attachés (DocumentFolderAttachment)
attached_folder_ids = _get_folder_ids_from_objects(user, edit_only=is_edit_check) attached_folder_ids = _get_folder_ids_from_objects(user, edit_only=is_edit_check)
if attached_folder_ids:
descendant_folder_ids = _collect_descendant_ids(attached_folder_ids)
attached_folder_ids |= descendant_folder_ids
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)
thematic_folder_ids: set[int] = set() thematic_folder_ids: set[int] = set()

View file

@ -256,7 +256,8 @@ class DocumentManagementService:
description=None, description=None,
content_object=None, content_object=None,
target_folder=None, target_folder=None,
unique_params=None unique_params=None,
visibility=None,
): ):
""" """
Ajoute un document avec la logique de versioning appropriée. Ajoute un document avec la logique de versioning appropriée.
@ -276,6 +277,7 @@ class DocumentManagementService:
détecté automatiquement depuis l'extension du fichier. détecté automatiquement depuis l'extension du fichier.
Le format est TOUJOURS inclus (ex: 'pdf', 'dxf', 'dwg'). Le format est TOUJOURS inclus (ex: 'pdf', 'dxf', 'dwg').
L'unicité est donc liée au tag + format (+ paramètres extra). L'unicité est donc liée au tag + format (+ paramètres extra).
visibility: VisibilityScope - Niveau de visibilité (optionnel, défaut SCOPED pour les projets)
Returns: Returns:
dict: { dict: {
@ -286,7 +288,7 @@ class DocumentManagementService:
} }
""" """
import os import os
from .models import DocumentAttachment from .models import DocumentAttachment, VisibilityScope
# Déterminer le répertoire cible # Déterminer le répertoire cible
if target_folder is None: if target_folder is None:
@ -329,6 +331,17 @@ class DocumentManagementService:
"message": _("New version added to existing document."), "message": _("New version added to existing document."),
} }
else: else:
# Déterminer la visibilité
if visibility is None:
if content_object and getattr(getattr(content_object, '_meta', None), 'model_name', None) == 'project':
doc_visibility = VisibilityScope.SCOPED
elif target_folder and getattr(target_folder, 'visibility', None) == VisibilityScope.SCOPED:
doc_visibility = VisibilityScope.SCOPED
else:
doc_visibility = VisibilityScope.INTERNAL
else:
doc_visibility = visibility
# Créer un nouveau document # Créer un nouveau document
doc_title = title or (file.name if hasattr(file, "name") else _("Document")) doc_title = title or (file.name if hasattr(file, "name") else _("Document"))
@ -337,6 +350,7 @@ class DocumentManagementService:
description=description or "", description=description or "",
created_by=user, created_by=user,
unique_params=normalized_params, unique_params=normalized_params,
visibility=doc_visibility,
) )
# Ajouter le tag # Ajouter le tag

View file

@ -1206,5 +1206,161 @@ class DocumentVisibilityAndIndependenceTests(TestCase):
self.assertNotIn(child.pk, ids_ex_f) self.assertNotIn(child.pk, ids_ex_f)
class ProjectDocumentsVisibilityTests(TestCase):
def setUp(self):
from common.models import UserConfig, Thematic, UserThematics
from contracts.models import Company, Team, CompanyMember
from projects.models import Project, ProjectUserAccess, ProjectTeamAccess
from documents.models import (
DocumentAttachment,
DocumentFolder,
DocumentFolderAttachment,
ManagedDocument,
VisibilityScope,
)
from django.contrib.contenttypes.models import ContentType
self.thematic = Thematic.objects.create(name="Voirie", code="VOI")
# Internal user
self.int_user = User.objects.create_user(username="intern_user", password="pwd")
self.int_config = UserConfig.objects.create(user=self.int_user, is_intern=True)
UserThematics.objects.create(user_config=self.int_config, thematic=self.thematic, can_view_projects=True)
# External user 1 (direct user access)
self.ext_user = User.objects.create_user(username="ext_user", password="pwd")
self.ext_config = UserConfig.objects.create(user=self.ext_user, is_intern=False)
# External user 2 (team access)
self.ext_team_user = User.objects.create_user(username="ext_team_user", password="pwd")
self.ext_team_config = UserConfig.objects.create(user=self.ext_team_user, is_intern=False)
self.company = Company.objects.create(name="Contractor Corp")
self.team = Team.objects.create(name="Project Team", company=self.company)
CompanyMember.objects.create(user=self.ext_team_user, company=self.company, team=self.team)
# External user 3 (no access)
self.ext_no_access = User.objects.create_user(username="ext_no_access", password="pwd")
self.ext_no_access_config = UserConfig.objects.create(user=self.ext_no_access, is_intern=False)
# Project
self.project = Project.objects.create(
code="P12345",
name="Boulevard Project",
created_by=self.int_user,
)
self.project.thematics.add(self.thematic)
# Grant direct access to ext_user
ProjectUserAccess.objects.create(project=self.project, user=self.ext_user, can_view=True)
# Grant team access to team
ProjectTeamAccess.objects.create(project=self.project, team=self.team, can_view=True)
# Create Project Folder (SCOPED)
self.project_folder = DocumentFolder.objects.create(
name="P12345 Documents",
slug="project-p12345-boulevard",
application_label="projects",
visibility=VisibilityScope.SCOPED,
created_by=self.int_user,
)
project_ct = ContentType.objects.get_for_model(self.project)
DocumentFolderAttachment.objects.create(
folder=self.project_folder,
content_type=project_ct,
object_id=self.project.pk,
is_default=True,
)
# Create Scoped Document
self.scoped_doc = ManagedDocument.objects.create(
title="Plan Execution",
visibility=VisibilityScope.SCOPED,
created_by=self.int_user,
)
self.scoped_doc.folders.add(self.project_folder)
DocumentAttachment.objects.create(
document=self.scoped_doc,
content_type=project_ct,
object_id=self.project.pk,
attached_by=self.int_user,
)
# Create Internal Document
self.internal_doc = ManagedDocument.objects.create(
title="Internal Budget",
visibility=VisibilityScope.INTERNAL,
created_by=self.int_user,
)
self.internal_doc.folders.add(self.project_folder)
DocumentAttachment.objects.create(
document=self.internal_doc,
content_type=project_ct,
object_id=self.project.pk,
attached_by=self.int_user,
)
def test_external_user_can_view_scoped_project_documents_and_folders(self):
from .permissions import (
filter_documents_for_user,
filter_folders_for_user,
user_has_document_permission,
user_has_folder_permission,
)
self.assertTrue(user_has_document_permission(self.ext_user, self.scoped_doc))
self.assertTrue(user_has_folder_permission(self.ext_user, self.project_folder))
doc_pks = set(filter_documents_for_user(ManagedDocument.objects.all(), self.ext_user).values_list("pk", flat=True))
folder_pks = set(filter_folders_for_user(DocumentFolder.objects.all(), self.ext_user).values_list("pk", flat=True))
self.assertIn(self.scoped_doc.pk, doc_pks)
self.assertIn(self.project_folder.pk, folder_pks)
def test_external_user_cannot_view_internal_project_documents(self):
from .permissions import (
filter_documents_for_user,
user_has_document_permission,
)
# External user cannot view internal doc
self.assertFalse(user_has_document_permission(self.ext_user, self.internal_doc))
ext_doc_pks = set(filter_documents_for_user(ManagedDocument.objects.all(), self.ext_user).values_list("pk", flat=True))
self.assertNotIn(self.internal_doc.pk, ext_doc_pks)
# Internal user CAN view internal doc
self.assertTrue(user_has_document_permission(self.int_user, self.internal_doc))
int_doc_pks = set(filter_documents_for_user(ManagedDocument.objects.all(), self.int_user).values_list("pk", flat=True))
self.assertIn(self.internal_doc.pk, int_doc_pks)
def test_external_user_via_team_access_can_view_scoped_project_documents(self):
from .permissions import (
filter_documents_for_user,
user_has_document_permission,
)
self.assertTrue(user_has_document_permission(self.ext_team_user, self.scoped_doc))
team_doc_pks = set(filter_documents_for_user(ManagedDocument.objects.all(), self.ext_team_user).values_list("pk", flat=True))
self.assertIn(self.scoped_doc.pk, team_doc_pks)
self.assertNotIn(self.internal_doc.pk, team_doc_pks)
def test_external_user_without_project_access_cannot_view_project_documents(self):
from .permissions import (
filter_documents_for_user,
user_has_document_permission,
user_has_folder_permission,
)
self.assertFalse(user_has_document_permission(self.ext_no_access, self.scoped_doc))
self.assertFalse(user_has_folder_permission(self.ext_no_access, self.project_folder))
no_acc_doc_pks = set(filter_documents_for_user(ManagedDocument.objects.all(), self.ext_no_access).values_list("pk", flat=True))
no_acc_folder_pks = set(filter_folders_for_user(DocumentFolder.objects.all(), self.ext_no_access).values_list("pk", flat=True))
self.assertNotIn(self.scoped_doc.pk, no_acc_doc_pks)
self.assertNotIn(self.project_folder.pk, no_acc_folder_pks)

View file

@ -183,9 +183,12 @@ class Command(BaseCommand):
""" """
title = os.path.splitext(proj_doc.filename)[0] or proj_doc.filename title = os.path.splitext(proj_doc.filename)[0] or proj_doc.filename
from documents.models import VisibilityScope
managed = ManagedDocument.objects.create( managed = ManagedDocument.objects.create(
title=title, title=title,
created_by=proj_doc.uploaded_by, created_by=proj_doc.uploaded_by,
visibility=VisibilityScope.SCOPED,
) )
# Build a DocumentVersion referencing the existing file path directly. # Build a DocumentVersion referencing the existing file path directly.

View file

@ -0,0 +1,68 @@
from django.db import migrations
def make_project_documents_and_folders_scoped(apps, schema_editor):
DocumentFolder = apps.get_model('documents', 'DocumentFolder')
ManagedDocument = apps.get_model('documents', 'ManagedDocument')
DocumentFolderAttachment = apps.get_model('documents', 'DocumentFolderAttachment')
DocumentAttachment = apps.get_model('documents', 'DocumentAttachment')
ContentType = apps.get_model('contenttypes', 'ContentType')
try:
project_ct = ContentType.objects.get(app_label='projects', model='project')
except ContentType.DoesNotExist:
return
# Update project folders from internal -> scoped
project_folder_ids = list(
DocumentFolderAttachment.objects.filter(
content_type=project_ct
).values_list('folder_id', flat=True)
)
if project_folder_ids:
DocumentFolder.objects.filter(
pk__in=project_folder_ids,
visibility='internal'
).update(visibility='scoped')
# Also update folders with application_label='projects'
DocumentFolder.objects.filter(
application_label='projects',
visibility='internal'
).update(visibility='scoped')
# Update project documents from internal -> scoped
project_doc_ids = list(
DocumentAttachment.objects.filter(
content_type=project_ct
).values_list('document_id', flat=True)
)
if project_doc_ids:
ManagedDocument.objects.filter(
pk__in=project_doc_ids,
visibility='internal'
).update(visibility='scoped')
# Also update documents in project folders
if project_folder_ids:
ManagedDocument.objects.filter(
folders__pk__in=project_folder_ids,
visibility='internal'
).update(visibility='scoped')
class Migration(migrations.Migration):
dependencies = [
('projects', '0001_initial'),
('documents', '0003_documentfolder_visibility_manageddocument_thematics_and_more'),
]
operations = [
migrations.RunPython(
make_project_documents_and_folders_scoped,
reverse_code=migrations.RunPython.noop,
),
]

View file

@ -20,18 +20,19 @@ def ensure_project_document_folder(project):
""" """
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from documents.models import DocumentFolder, DocumentFolderAttachment from documents.models import DocumentFolder, DocumentFolderAttachment, VisibilityScope
parent = DocumentFolder.objects.filter(slug="projects", is_predefined=True).first() parent = DocumentFolder.objects.filter(slug="projects", is_predefined=True).first()
folder_name = f"{project.code} \u2013 {project.name[:60]}" folder_name = f"{project.code} \u2013 {project.name[:60]}"
folder_slug = f"project-{project.code.lower()}-{slugify(project.name)[:80]}" folder_slug = f"project-{project.code.lower()}-{slugify(project.name)[:80]}"
folder, _ = DocumentFolder.objects.get_or_create( folder, created = DocumentFolder.objects.get_or_create(
slug=folder_slug, slug=folder_slug,
defaults={ defaults={
"name": folder_name, "name": folder_name,
"application_label": "projects", "application_label": "projects",
"visibility": VisibilityScope.SCOPED,
}, },
) )

View file

@ -569,6 +569,7 @@ let docAvailableFolders = [];
.then(data => { .then(data => {
docConfig.use_managed_documents = data.use_managed_documents; docConfig.use_managed_documents = data.use_managed_documents;
docConfig.can_add_unique_tag_documents = data.can_add_unique_tag_documents; docConfig.can_add_unique_tag_documents = data.can_add_unique_tag_documents;
docConfig.can_change_visibility = data.can_change_visibility;
docAvailableTags = data.tags || []; docAvailableTags = data.tags || [];
docAvailableFolders = data.folders || []; docAvailableFolders = data.folders || [];
}) })
@ -641,6 +642,32 @@ function renderDocFileFields() {
} }
}); });
// ── Visibility select ──
if (docConfig.can_change_visibility) {
const visSel = document.createElement('select');
visSel.name = `visibility_${index}`;
visSel.classList.add('form-select', 'w-auto');
visSel.style.minWidth = '160px';
const optScoped = document.createElement('option');
optScoped.value = 'scoped';
optScoped.textContent = gettext('Externe & Interne');
optScoped.selected = true;
visSel.appendChild(optScoped);
const optInternal = document.createElement('option');
optInternal.value = 'internal';
optInternal.textContent = gettext('Interne uniquement');
visSel.appendChild(optInternal);
const optPrivate = document.createElement('option');
optPrivate.value = 'private';
optPrivate.textContent = gettext('Privé');
visSel.appendChild(optPrivate);
row.appendChild(visSel);
}
} else { } else {
// ── Legacy: hidden input keeps count consistent ── // ── Legacy: hidden input keeps count consistent ──
const hidden = document.createElement('input'); const hidden = document.createElement('input');

View file

@ -218,6 +218,7 @@ let docAvailableFolders = [];
.then(data => { .then(data => {
docConfig.use_managed_documents = data.use_managed_documents; docConfig.use_managed_documents = data.use_managed_documents;
docConfig.can_add_unique_tag_documents = data.can_add_unique_tag_documents; docConfig.can_add_unique_tag_documents = data.can_add_unique_tag_documents;
docConfig.can_change_visibility = data.can_change_visibility;
docAvailableTags = data.tags || []; docAvailableTags = data.tags || [];
docAvailableFolders = data.folders || []; docAvailableFolders = data.folders || [];
}) })
@ -259,6 +260,30 @@ function renderDocFileFields() {
tagSel.appendChild(opt); tagSel.appendChild(opt);
}); });
row.appendChild(tagSel); row.appendChild(tagSel);
if (docConfig.can_change_visibility) {
const visSel = document.createElement('select');
visSel.name = `visibility_${index}`;
visSel.classList.add('form-select', 'form-select-sm', 'w-auto');
const optScoped = document.createElement('option');
optScoped.value = 'scoped';
optScoped.textContent = gettext('Externe & Interne');
optScoped.selected = true;
visSel.appendChild(optScoped);
const optInternal = document.createElement('option');
optInternal.value = 'internal';
optInternal.textContent = gettext('Interne uniquement');
visSel.appendChild(optInternal);
const optPrivate = document.createElement('option');
optPrivate.value = 'private';
optPrivate.textContent = gettext('Privé');
visSel.appendChild(optPrivate);
row.appendChild(visSel);
}
} else { } else {
const hidden = document.createElement('input'); const hidden = document.createElement('input');
hidden.type = 'hidden'; hidden.type = 'hidden';

View file

@ -590,6 +590,19 @@ def projects_add_document(request, project_id):
if unique_param_value: if unique_param_value:
unique_params = {'cables': unique_param_value} unique_params = {'cables': unique_param_value}
# Visibilité
from documents.models import VisibilityScope
from documents.permissions import user_can_change_visibility
raw_visibility = request.POST.get(f'visibility_{i}', '').strip()
visibility = None
if raw_visibility and raw_visibility in dict(VisibilityScope.CHOICES):
if user_can_change_visibility(request.user):
visibility = raw_visibility
else:
visibility = VisibilityScope.SCOPED
else:
visibility = VisibilityScope.SCOPED
DocumentManagementService.add_document( DocumentManagementService.add_document(
file=file, file=file,
tag=tag, tag=tag,
@ -598,6 +611,7 @@ def projects_add_document(request, project_id):
content_object=project, content_object=project,
target_folder=target_folder, target_folder=target_folder,
unique_params=unique_params, unique_params=unique_params,
visibility=visibility,
) )
messages.success(request, _("Document(s) sauvegardé(s) avec succès !")) messages.success(request, _("Document(s) sauvegardé(s) avec succès !"))
@ -905,9 +919,13 @@ def projects_document_config(request, project_id):
folders = DocumentManagementService.get_subfolders_for_object(project) folders = DocumentManagementService.get_subfolders_for_object(project)
from documents.permissions import user_can_change_visibility
can_change_vis = user_can_change_visibility(user)
return JsonResponse({ return JsonResponse({
'use_managed_documents': True, 'use_managed_documents': True,
'can_add_unique_tag_documents': can_add_unique, 'can_add_unique_tag_documents': can_add_unique,
'can_change_visibility': can_change_vis,
'tags': tags_data, 'tags': tags_data,
'folders': folders, 'folders': folders,
}) })