From ee90cf3173c56b6c035629c0fe509a82cf665141 Mon Sep 17 00:00:00 2001 From: Karl Determe Date: Tue, 1 Sep 2026 12:58:09 +0200 Subject: [PATCH] feat: implement document management service with recursive versioning and folder navigation logic --- loko/documents/permissions.py | 79 +++++---- loko/documents/services.py | 18 +- loko/documents/tests.py | 156 ++++++++++++++++++ .../commands/create_project_folders.py | 3 + ...002_update_project_documents_visibility.py | 68 ++++++++ loko/projects/signals.py | 5 +- .../static/projects/projects_detail.js | 27 +++ .../projects/projects_detail_mobile.html | 25 +++ loko/projects/views.py | 18 ++ 9 files changed, 353 insertions(+), 46 deletions(-) create mode 100644 loko/projects/migrations/0002_update_project_documents_visibility.py diff --git a/loko/documents/permissions.py b/loko/documents/permissions.py index 8165462..bd99eb9 100644 --- a/loko/documents/permissions.py +++ b/loko/documents/permissions.py @@ -67,6 +67,13 @@ def filter_documents_for_user( # 2. Documents qualifiés par thématiques & contrats (attachments et direct thematics) attached_doc_ids = _get_document_ids_from_objects(user, edit_only=is_edit_check) + 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) 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): 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 @@ -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): 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 @@ -605,14 +628,10 @@ def _user_can_access_content_object(user, content_object, edit_only: bool = Fals 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 + from projects.permissions import can_view_project, can_add_or_edit_project + if edit_only: + return can_add_or_edit_project(user, content_object) + return can_view_project(user, content_object) if content_type.app_label == 'contracts' and model_name == 'contract': 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 if project_ct: - from projects.models import Project, ProjectUserAccess - from common.models import UserThematics + from projects.permissions import filter_viewable_projects_for_user - thematic_project_ids: set[int] = set() - accessible_thematics = UserThematics.objects.filter( - user_config=config, can_view_projects=True + accessible_project_ids = set( + filter_viewable_projects_for_user(user).values_list('pk', flat=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_folder_ids = set( DocumentFolderAttachment.objects.filter( @@ -795,25 +800,11 @@ def _get_document_ids_from_objects(user, *, edit_only: bool) -> set[int]: project_ct = None if project_ct: - from projects.models import Project, ProjectUserAccess - from common.models import UserThematics + from projects.permissions import filter_viewable_projects_for_user - thematic_project_ids: set[int] = set() - accessible_thematics = UserThematics.objects.filter( - user_config=config, can_view_projects=True + accessible_project_ids = set( + filter_viewable_projects_for_user(user).values_list('pk', flat=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( @@ -920,6 +911,10 @@ def filter_folders_for_user( # 2. Scope qualifié : thématiques directes ou objets attachés (DocumentFolderAttachment) attached_folder_ids = _get_folder_ids_from_objects(user, edit_only=is_edit_check) + 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) thematic_folder_ids: set[int] = set() diff --git a/loko/documents/services.py b/loko/documents/services.py index 781b5c2..0946f1d 100644 --- a/loko/documents/services.py +++ b/loko/documents/services.py @@ -256,7 +256,8 @@ class DocumentManagementService: description=None, content_object=None, target_folder=None, - unique_params=None + unique_params=None, + visibility=None, ): """ Ajoute un document avec la logique de versioning appropriée. @@ -276,6 +277,7 @@ class DocumentManagementService: détecté automatiquement depuis l'extension du fichier. Le format est TOUJOURS inclus (ex: 'pdf', 'dxf', 'dwg'). 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: dict: { @@ -286,7 +288,7 @@ class DocumentManagementService: } """ import os - from .models import DocumentAttachment + from .models import DocumentAttachment, VisibilityScope # Déterminer le répertoire cible if target_folder is None: @@ -329,6 +331,17 @@ class DocumentManagementService: "message": _("New version added to existing document."), } 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 doc_title = title or (file.name if hasattr(file, "name") else _("Document")) @@ -337,6 +350,7 @@ class DocumentManagementService: description=description or "", created_by=user, unique_params=normalized_params, + visibility=doc_visibility, ) # Ajouter le tag diff --git a/loko/documents/tests.py b/loko/documents/tests.py index a8c6893..931d252 100644 --- a/loko/documents/tests.py +++ b/loko/documents/tests.py @@ -1206,5 +1206,161 @@ class DocumentVisibilityAndIndependenceTests(TestCase): 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) + + + \ No newline at end of file diff --git a/loko/projects/management/commands/create_project_folders.py b/loko/projects/management/commands/create_project_folders.py index 0f1afc0..60e4729 100644 --- a/loko/projects/management/commands/create_project_folders.py +++ b/loko/projects/management/commands/create_project_folders.py @@ -183,9 +183,12 @@ class Command(BaseCommand): """ title = os.path.splitext(proj_doc.filename)[0] or proj_doc.filename + from documents.models import VisibilityScope + managed = ManagedDocument.objects.create( title=title, created_by=proj_doc.uploaded_by, + visibility=VisibilityScope.SCOPED, ) # Build a DocumentVersion referencing the existing file path directly. diff --git a/loko/projects/migrations/0002_update_project_documents_visibility.py b/loko/projects/migrations/0002_update_project_documents_visibility.py new file mode 100644 index 0000000..e51b19c --- /dev/null +++ b/loko/projects/migrations/0002_update_project_documents_visibility.py @@ -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, + ), + ] diff --git a/loko/projects/signals.py b/loko/projects/signals.py index 522035c..a20f27e 100644 --- a/loko/projects/signals.py +++ b/loko/projects/signals.py @@ -20,18 +20,19 @@ def ensure_project_document_folder(project): """ 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() folder_name = f"{project.code} \u2013 {project.name[:60]}" 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, defaults={ "name": folder_name, "application_label": "projects", + "visibility": VisibilityScope.SCOPED, }, ) diff --git a/loko/projects/static/projects/projects_detail.js b/loko/projects/static/projects/projects_detail.js index d7929bd..833b5b7 100644 --- a/loko/projects/static/projects/projects_detail.js +++ b/loko/projects/static/projects/projects_detail.js @@ -569,6 +569,7 @@ let docAvailableFolders = []; .then(data => { docConfig.use_managed_documents = data.use_managed_documents; docConfig.can_add_unique_tag_documents = data.can_add_unique_tag_documents; + docConfig.can_change_visibility = data.can_change_visibility; docAvailableTags = data.tags || []; 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 { // ── Legacy: hidden input keeps count consistent ── const hidden = document.createElement('input'); diff --git a/loko/projects/templates/projects/projects_detail_mobile.html b/loko/projects/templates/projects/projects_detail_mobile.html index 5bf6129..23c16be 100644 --- a/loko/projects/templates/projects/projects_detail_mobile.html +++ b/loko/projects/templates/projects/projects_detail_mobile.html @@ -218,6 +218,7 @@ let docAvailableFolders = []; .then(data => { docConfig.use_managed_documents = data.use_managed_documents; docConfig.can_add_unique_tag_documents = data.can_add_unique_tag_documents; + docConfig.can_change_visibility = data.can_change_visibility; docAvailableTags = data.tags || []; docAvailableFolders = data.folders || []; }) @@ -259,6 +260,30 @@ function renderDocFileFields() { tagSel.appendChild(opt); }); 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 { const hidden = document.createElement('input'); hidden.type = 'hidden'; diff --git a/loko/projects/views.py b/loko/projects/views.py index f12547b..0cc3a1b 100644 --- a/loko/projects/views.py +++ b/loko/projects/views.py @@ -590,6 +590,19 @@ def projects_add_document(request, project_id): if 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( file=file, tag=tag, @@ -598,6 +611,7 @@ def projects_add_document(request, project_id): content_object=project, target_folder=target_folder, unique_params=unique_params, + visibility=visibility, ) 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) + from documents.permissions import user_can_change_visibility + can_change_vis = user_can_change_visibility(user) + return JsonResponse({ 'use_managed_documents': True, 'can_add_unique_tag_documents': can_add_unique, + 'can_change_visibility': can_change_vis, 'tags': tags_data, 'folders': folders, })