From 32eec7b442d6357d70b008e2cb0dcbf175290c83 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Sat, 29 Aug 2026 19:41:47 +0200 Subject: [PATCH] feat: implement AJAX-based folder autocomplete for Select2 widgets and add corresponding view and tests --- loko/documents/forms.py | 37 +++++----- .../templates/documents/document_detail.html | 60 ++++++++++++---- .../templates/documents/document_form.html | 19 ++++- .../documents/documentfolder_form.html | 20 +++++- loko/documents/tests.py | 54 ++++++++++++-- loko/documents/urls.py | 1 + loko/documents/views.py | 72 ++++++++++++++----- 7 files changed, 212 insertions(+), 51 deletions(-) diff --git a/loko/documents/forms.py b/loko/documents/forms.py index ccb1615..256d74c 100644 --- a/loko/documents/forms.py +++ b/loko/documents/forms.py @@ -66,15 +66,17 @@ class DocumentUpdateForm(forms.ModelForm): self.fields["folders"].widget.attrs.setdefault("class", "form-select") self.fields["folders"].required = False if self.user: - accessible_folders = filter_folders_for_user( - DocumentFolder.objects.prefetch_related("parent_folders"), + self.fields["folders"].queryset = filter_folders_for_user( + DocumentFolder.objects.all(), self.user, required_permission=ManagedDocument.PERMISSION_VIEW, ) - sorted_folders = sorted(accessible_folders, key=lambda f: f.get_full_path().lower()) - self.fields["folders"].queryset = accessible_folders - self.fields["folders"].choices = [(f.pk, f.get_full_path()) for f in sorted_folders] self.fields["folders"].label_from_instance = lambda obj: obj.get_full_path() + if self.instance and self.instance.pk: + current_folders = list(self.instance.folders.all().prefetch_related("parent_folders")) + self.fields["folders"].choices = [(f.pk, f.get_full_path()) for f in current_folders] + else: + self.fields["folders"].choices = [] def clean_visibility(self): if self.user and not user_can_change_visibility(self.user): @@ -111,15 +113,19 @@ class ManagedDocumentForm(forms.ModelForm): # Limiter les folders accessibles à l'utilisateur if user: - accessible_folders = filter_folders_for_user( - DocumentFolder.objects.prefetch_related("parent_folders"), + self.fields["folders"].queryset = filter_folders_for_user( + DocumentFolder.objects.all(), user, required_permission=ManagedDocument.PERMISSION_VIEW ) - sorted_folders = sorted(accessible_folders, key=lambda f: f.get_full_path().lower()) - self.fields["folders"].queryset = accessible_folders - self.fields["folders"].choices = [(f.pk, f.get_full_path()) for f in sorted_folders] self.fields["folders"].label_from_instance = lambda obj: obj.get_full_path() + if initial_folder: + self.fields["folders"].choices = [(initial_folder.pk, initial_folder.get_full_path())] + elif self.instance and self.instance.pk: + current_folders = list(self.instance.folders.all().prefetch_related("parent_folders")) + self.fields["folders"].choices = [(f.pk, f.get_full_path()) for f in current_folders] + else: + self.fields["folders"].choices = [] # Pré-sélectionner le folder initial si fourni if initial_folder and not self.instance.pk: @@ -480,14 +486,13 @@ class DocumentFolderForm(forms.ModelForm): instance = getattr(self, "instance", None) if instance and instance.pk: excluded_ids = {instance.pk, *self._get_descendant_ids(instance)} - parent_qs = ( - DocumentFolder.objects.prefetch_related("parent_folders").exclude(pk__in=excluded_ids) - ) + parent_qs = DocumentFolder.objects.exclude(pk__in=excluded_ids) + current_parents = list(instance.parent_folders.all().prefetch_related("parent_folders")) + self.fields["parent_folders"].choices = [(f.pk, f.get_full_path()) for f in current_parents] else: - parent_qs = DocumentFolder.objects.prefetch_related("parent_folders") - sorted_parents = sorted(parent_qs, key=lambda f: f.get_full_path().lower()) + parent_qs = DocumentFolder.objects.all() + self.fields["parent_folders"].choices = [] self.fields["parent_folders"].queryset = parent_qs - self.fields["parent_folders"].choices = [(f.pk, f.get_full_path()) for f in sorted_parents] self.fields["parent_folders"].label_from_instance = lambda obj: obj.get_full_path() if "thematics" in self.fields: diff --git a/loko/documents/templates/documents/document_detail.html b/loko/documents/templates/documents/document_detail.html index 9d792b2..500d8ab 100644 --- a/loko/documents/templates/documents/document_detail.html +++ b/loko/documents/templates/documents/document_detail.html @@ -129,9 +129,6 @@
@@ -760,8 +757,8 @@ document.addEventListener('DOMContentLoaded', function () {
@@ -794,13 +791,32 @@ document.addEventListener('DOMContentLoaded', function () { $(document).ready(function() { // Select2 for document folders in modal $('#editDocumentModal').on('shown.bs.modal', function() { - $('#id_doc_folders').select2({ - dropdownParent: $('#editDocumentModal'), - theme: 'bootstrap-5', - placeholder: "{% translate 'Rechercher des répertoires...' %}", - allowClear: true, - width: '100%' - }); + if (!$('#id_doc_folders').hasClass('select2-hidden-accessible')) { + $('#id_doc_folders').select2({ + dropdownParent: $('#editDocumentModal'), + theme: 'bootstrap-5', + placeholder: "{% translate 'Rechercher des répertoires...' %}", + allowClear: true, + width: '100%', + ajax: { + url: "{% url 'documents:folder_autocomplete' %}", + dataType: 'json', + delay: 250, + data: function (params) { + return { + q: params.term || '' + }; + }, + processResults: function (data) { + return { + results: data.results + }; + }, + cache: true + }, + minimumInputLength: 0 + }); + } }); // Select2 for add folder select in card @@ -809,7 +825,25 @@ document.addEventListener('DOMContentLoaded', function () { theme: 'bootstrap-5', placeholder: "{% translate 'Sélectionner un répertoire...' %}", allowClear: true, - width: '100%' + width: '100%', + ajax: { + url: "{% url 'documents:folder_autocomplete' %}", + dataType: 'json', + delay: 250, + data: function (params) { + return { + q: params.term || '', + exclude_doc: "{{ document.pk }}" + }; + }, + processResults: function (data) { + return { + results: data.results + }; + }, + cache: true + }, + minimumInputLength: 0 }); } diff --git a/loko/documents/templates/documents/document_form.html b/loko/documents/templates/documents/document_form.html index 997297f..9b091ab 100644 --- a/loko/documents/templates/documents/document_form.html +++ b/loko/documents/templates/documents/document_form.html @@ -114,7 +114,24 @@ theme: 'bootstrap-5', placeholder: "{% translate 'Rechercher des répertoires...' %}", allowClear: true, - width: '100%' + width: '100%', + ajax: { + url: "{% url 'documents:folder_autocomplete' %}", + dataType: 'json', + delay: 250, + data: function (params) { + return { + q: params.term || '' + }; + }, + processResults: function (data) { + return { + results: data.results + }; + }, + cache: true + }, + minimumInputLength: 0 }); } if ($('#id_thematics').length) { diff --git a/loko/documents/templates/documents/documentfolder_form.html b/loko/documents/templates/documents/documentfolder_form.html index 652cb82..a4017c5 100644 --- a/loko/documents/templates/documents/documentfolder_form.html +++ b/loko/documents/templates/documents/documentfolder_form.html @@ -87,7 +87,25 @@ theme: 'bootstrap-5', placeholder: "{% translate 'Rechercher des répertoires parents...' %}", allowClear: true, - width: '100%' + width: '100%', + ajax: { + url: "{% url 'documents:folder_autocomplete' %}", + dataType: 'json', + delay: 250, + data: function (params) { + return { + q: params.term || '', + exclude_folder: "{{ folder.pk|default:'' }}" + }; + }, + processResults: function (data) { + return { + results: data.results + }; + }, + cache: true + }, + minimumInputLength: 0 }); } if ($('#id_thematics').length) { diff --git a/loko/documents/tests.py b/loko/documents/tests.py index 6ca5998..a8c6893 100644 --- a/loko/documents/tests.py +++ b/loko/documents/tests.py @@ -1090,14 +1090,16 @@ class DocumentVisibilityAndIndependenceTests(TestCase): self.assertEqual(str(asbuilt), "Anderlecht / Carrefour 1 / As-Built") # Test ManagedDocumentForm choices - form = ManagedDocumentForm(user=self.creator) - folder_choices = dict(form.fields["folders"].choices) + form_initial = ManagedDocumentForm(user=self.creator, initial_folder=asbuilt) + folder_choices = dict(form_initial.fields["folders"].choices) self.assertEqual(folder_choices[asbuilt.pk], "Anderlecht / Carrefour 1 / As-Built") + self.assertEqual(form_initial.fields["folders"].label_from_instance(asbuilt), "Anderlecht / Carrefour 1 / As-Built") - # Test DocumentFolderForm choices - folder_form = DocumentFolderForm(user=self.creator) + # Test DocumentFolderForm choices for bound instance + folder_form = DocumentFolderForm(user=self.creator, instance=asbuilt) parent_choices = dict(folder_form.fields["parent_folders"].choices) self.assertEqual(parent_choices[carrefour.pk], "Anderlecht / Carrefour 1") + self.assertEqual(folder_form.fields["parent_folders"].label_from_instance(carrefour), "Anderlecht / Carrefour 1") def test_user_display_name_with_company(self): from common.utils import get_user_display_name @@ -1159,6 +1161,50 @@ class DocumentVisibilityAndIndependenceTests(TestCase): self.assertEqual(doc.shares.get(user=u1).permission, ManagedDocument.PERMISSION_EDIT) self.assertEqual(doc.shares.get(user=u2).permission, ManagedDocument.PERMISSION_EDIT) + def test_folder_autocomplete_view(self): + from .models import VisibilityScope + self.client.force_login(self.creator) + parent = DocumentFolder.objects.create(name="Parent Dir", created_by=self.creator) + child = DocumentFolder.objects.create(name="Child Dir", created_by=self.creator) + child.parent_folders.add(parent) + + doc = ManagedDocument.objects.create( + title="Doc In Child", + created_by=self.creator, + visibility=VisibilityScope.INTERNAL, + ) + doc.folders.add(child) + + # Autocomplete without filter + url = reverse("documents:folder_autocomplete") + res = self.client.get(url) + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertIn("results", data) + texts = [item["text"] for item in data["results"]] + self.assertTrue(any("Parent Dir / Child Dir" in t for t in texts)) + + # Search query matching child + res_q = self.client.get(url, {"q": "Child"}) + data_q = res_q.json() + self.assertEqual(len(data_q["results"]), 1) + self.assertEqual(data_q["results"][0]["id"], child.pk) + self.assertEqual(data_q["results"][0]["text"], "Parent Dir / Child Dir") + + # Exclude doc + res_ex_doc = self.client.get(url, {"exclude_doc": doc.pk}) + data_ex_doc = res_ex_doc.json() + ids_ex = [item["id"] for item in data_ex_doc["results"]] + self.assertNotIn(child.pk, ids_ex) + self.assertIn(parent.pk, ids_ex) + + # Exclude folder (parent excludes child if excluding parent or child excludes descendants) + res_ex_folder = self.client.get(url, {"exclude_folder": parent.pk}) + data_ex_folder = res_ex_folder.json() + ids_ex_f = [item["id"] for item in data_ex_folder["results"]] + self.assertNotIn(parent.pk, ids_ex_f) + self.assertNotIn(child.pk, ids_ex_f) + \ No newline at end of file diff --git a/loko/documents/urls.py b/loko/documents/urls.py index 6903a09..7833cf1 100644 --- a/loko/documents/urls.py +++ b/loko/documents/urls.py @@ -7,6 +7,7 @@ app_name = "documents" urlpatterns = [ path("", views.DocumentListView.as_view(), name="list"), + path("folders/autocomplete/", views.FolderAutocompleteView.as_view(), name="folder_autocomplete"), path("create/", views.DocumentCreateView.as_view(), name="create"), path("/delete/", views.DocumentDeleteView.as_view(), name="delete"), path("folders/create/", views.DocumentFolderCreateView.as_view(), name="folder_create"), diff --git a/loko/documents/views.py b/loko/documents/views.py index a2adb94..eff2f37 100644 --- a/loko/documents/views.py +++ b/loko/documents/views.py @@ -1095,22 +1095,6 @@ class DocumentDetailView(DocumentsAppAccessMixin, LoginRequiredMixin, DetailView context["can_share_document"] = user_can_share(self.request.user) and can_edit context["can_change_visibility"] = user_can_change_visibility(self.request.user) - # Available folders/tags for add forms (exclude already associated ones) - existing_folder_ids = list(document.folders.values_list("pk", flat=True)) - accessible_folders = filter_folders_for_user( - DocumentFolder.objects.prefetch_related("parent_folders"), - self.request.user, - required_permission=ManagedDocument.PERMISSION_VIEW, - ) - context["available_folders"] = sorted( - accessible_folders.exclude(pk__in=existing_folder_ids), - key=lambda f: f.get_full_path().lower(), - ) - context["all_accessible_folders"] = sorted( - accessible_folders, - key=lambda f: f.get_full_path().lower(), - ) - existing_tag_ids = list(document.tags.values_list("pk", flat=True)) context["available_tags"] = DocumentTag.objects.exclude( pk__in=existing_tag_ids @@ -1567,6 +1551,62 @@ class SearchObjectsView(LoginRequiredMixin, View): return JsonResponse({"results": results}) +class FolderAutocompleteView(DocumentsAppAccessMixin, LoginRequiredMixin, View): + def get(self, request): + from django.db.models import Q + + q = request.GET.get("q", "").strip() + exclude_doc_id = request.GET.get("exclude_doc") + exclude_folder_id = request.GET.get("exclude_folder") + + queryset = filter_folders_for_user( + DocumentFolder.objects.all(), + request.user, + required_permission=ManagedDocument.PERMISSION_VIEW, + ) + + if exclude_doc_id: + try: + doc = ManagedDocument.objects.get(pk=int(exclude_doc_id)) + existing_pks = doc.folders.values_list("pk", flat=True) + queryset = queryset.exclude(pk__in=existing_pks) + except (ManagedDocument.DoesNotExist, ValueError): + pass + + if exclude_folder_id: + try: + folder = DocumentFolder.objects.get(pk=int(exclude_folder_id)) + descendant_ids = set() + stack = [folder.pk] + while stack: + curr = stack.pop() + if curr in descendant_ids: + continue + descendant_ids.add(curr) + child_ids = DocumentFolder.objects.filter(parent_folders__pk=curr).values_list("pk", flat=True) + stack.extend(child_ids) + queryset = queryset.exclude(pk__in=descendant_ids) + except (DocumentFolder.DoesNotExist, ValueError): + pass + + if q: + queryset = queryset.filter( + Q(name__icontains=q) | Q(slug__icontains=q) | Q(parent_folders__name__icontains=q) + ).distinct() + + queryset = queryset.prefetch_related("parent_folders")[:100] + + results = [] + for folder in queryset: + results.append({ + "id": folder.pk, + "text": folder.get_full_path(), + }) + + results.sort(key=lambda item: item["text"].lower()) + return JsonResponse({"results": results}) + + from pdf2image import convert_from_path, pdfinfo_from_path from io import BytesIO from django.core.cache import cache