feat: implement AJAX-based folder autocomplete for Select2 widgets and add corresponding view and tests

This commit is contained in:
kdeterme 2026-08-29 19:41:47 +02:00
parent 15b058e246
commit 32eec7b442
7 changed files with 212 additions and 51 deletions

View file

@ -66,15 +66,17 @@ class DocumentUpdateForm(forms.ModelForm):
self.fields["folders"].widget.attrs.setdefault("class", "form-select") self.fields["folders"].widget.attrs.setdefault("class", "form-select")
self.fields["folders"].required = False self.fields["folders"].required = False
if self.user: if self.user:
accessible_folders = filter_folders_for_user( self.fields["folders"].queryset = filter_folders_for_user(
DocumentFolder.objects.prefetch_related("parent_folders"), DocumentFolder.objects.all(),
self.user, self.user,
required_permission=ManagedDocument.PERMISSION_VIEW, 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() 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): def clean_visibility(self):
if self.user and not user_can_change_visibility(self.user): 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 # Limiter les folders accessibles à l'utilisateur
if user: if user:
accessible_folders = filter_folders_for_user( self.fields["folders"].queryset = filter_folders_for_user(
DocumentFolder.objects.prefetch_related("parent_folders"), DocumentFolder.objects.all(),
user, user,
required_permission=ManagedDocument.PERMISSION_VIEW 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() 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 # Pré-sélectionner le folder initial si fourni
if initial_folder and not self.instance.pk: if initial_folder and not self.instance.pk:
@ -480,14 +486,13 @@ class DocumentFolderForm(forms.ModelForm):
instance = getattr(self, "instance", None) instance = getattr(self, "instance", None)
if instance and instance.pk: if instance and instance.pk:
excluded_ids = {instance.pk, *self._get_descendant_ids(instance)} excluded_ids = {instance.pk, *self._get_descendant_ids(instance)}
parent_qs = ( parent_qs = DocumentFolder.objects.exclude(pk__in=excluded_ids)
DocumentFolder.objects.prefetch_related("parent_folders").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: else:
parent_qs = DocumentFolder.objects.prefetch_related("parent_folders") parent_qs = DocumentFolder.objects.all()
sorted_parents = sorted(parent_qs, key=lambda f: f.get_full_path().lower()) self.fields["parent_folders"].choices = []
self.fields["parent_folders"].queryset = parent_qs 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() self.fields["parent_folders"].label_from_instance = lambda obj: obj.get_full_path()
if "thematics" in self.fields: if "thematics" in self.fields:

View file

@ -129,9 +129,6 @@
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<select name="folder_id" id="id_add_folder_select" class="form-select" required> <select name="folder_id" id="id_add_folder_select" class="form-select" required>
<option value="">-- {% translate "Select a folder" %} --</option> <option value="">-- {% translate "Select a folder" %} --</option>
{% for f in available_folders %}
<option value="{{ f.pk }}">{{ f.get_full_path }}</option>
{% endfor %}
</select> </select>
<button type="submit" class="btn btn-primary">{% translate "Add" %}</button> <button type="submit" class="btn btn-primary">{% translate "Add" %}</button>
</div> </div>
@ -760,8 +757,8 @@ document.addEventListener('DOMContentLoaded', function () {
<div class="mb-3"> <div class="mb-3">
<label for="id_doc_folders" class="form-label fw-bold">{% translate "Répertoires" %}</label> <label for="id_doc_folders" class="form-label fw-bold">{% translate "Répertoires" %}</label>
<select name="folders" id="id_doc_folders" class="form-select" multiple> <select name="folders" id="id_doc_folders" class="form-select" multiple>
{% for f in all_accessible_folders %} {% for f in document.folders.all %}
<option value="{{ f.pk }}" {% if f in document.folders.all %}selected{% endif %}>{{ f.get_full_path }}</option> <option value="{{ f.pk }}" selected>{{ f.get_full_path }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@ -794,13 +791,32 @@ document.addEventListener('DOMContentLoaded', function () {
$(document).ready(function() { $(document).ready(function() {
// Select2 for document folders in modal // Select2 for document folders in modal
$('#editDocumentModal').on('shown.bs.modal', function() { $('#editDocumentModal').on('shown.bs.modal', function() {
$('#id_doc_folders').select2({ if (!$('#id_doc_folders').hasClass('select2-hidden-accessible')) {
dropdownParent: $('#editDocumentModal'), $('#id_doc_folders').select2({
theme: 'bootstrap-5', dropdownParent: $('#editDocumentModal'),
placeholder: "{% translate 'Rechercher des répertoires...' %}", theme: 'bootstrap-5',
allowClear: true, placeholder: "{% translate 'Rechercher des répertoires...' %}",
width: '100%' 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 // Select2 for add folder select in card
@ -809,7 +825,25 @@ document.addEventListener('DOMContentLoaded', function () {
theme: 'bootstrap-5', theme: 'bootstrap-5',
placeholder: "{% translate 'Sélectionner un répertoire...' %}", placeholder: "{% translate 'Sélectionner un répertoire...' %}",
allowClear: true, 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
}); });
} }

View file

@ -114,7 +114,24 @@
theme: 'bootstrap-5', theme: 'bootstrap-5',
placeholder: "{% translate 'Rechercher des répertoires...' %}", placeholder: "{% translate 'Rechercher des répertoires...' %}",
allowClear: true, 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) { if ($('#id_thematics').length) {

View file

@ -87,7 +87,25 @@
theme: 'bootstrap-5', theme: 'bootstrap-5',
placeholder: "{% translate 'Rechercher des répertoires parents...' %}", placeholder: "{% translate 'Rechercher des répertoires parents...' %}",
allowClear: true, 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) { if ($('#id_thematics').length) {

View file

@ -1090,14 +1090,16 @@ class DocumentVisibilityAndIndependenceTests(TestCase):
self.assertEqual(str(asbuilt), "Anderlecht / Carrefour 1 / As-Built") self.assertEqual(str(asbuilt), "Anderlecht / Carrefour 1 / As-Built")
# Test ManagedDocumentForm choices # Test ManagedDocumentForm choices
form = ManagedDocumentForm(user=self.creator) form_initial = ManagedDocumentForm(user=self.creator, initial_folder=asbuilt)
folder_choices = dict(form.fields["folders"].choices) folder_choices = dict(form_initial.fields["folders"].choices)
self.assertEqual(folder_choices[asbuilt.pk], "Anderlecht / Carrefour 1 / As-Built") 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 # Test DocumentFolderForm choices for bound instance
folder_form = DocumentFolderForm(user=self.creator) folder_form = DocumentFolderForm(user=self.creator, instance=asbuilt)
parent_choices = dict(folder_form.fields["parent_folders"].choices) parent_choices = dict(folder_form.fields["parent_folders"].choices)
self.assertEqual(parent_choices[carrefour.pk], "Anderlecht / Carrefour 1") 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): def test_user_display_name_with_company(self):
from common.utils import get_user_display_name 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=u1).permission, ManagedDocument.PERMISSION_EDIT)
self.assertEqual(doc.shares.get(user=u2).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)

View file

@ -7,6 +7,7 @@ app_name = "documents"
urlpatterns = [ urlpatterns = [
path("", views.DocumentListView.as_view(), name="list"), 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("create/", views.DocumentCreateView.as_view(), name="create"),
path("<int:pk>/delete/", views.DocumentDeleteView.as_view(), name="delete"), path("<int:pk>/delete/", views.DocumentDeleteView.as_view(), name="delete"),
path("folders/create/", views.DocumentFolderCreateView.as_view(), name="folder_create"), path("folders/create/", views.DocumentFolderCreateView.as_view(), name="folder_create"),

View file

@ -1095,22 +1095,6 @@ class DocumentDetailView(DocumentsAppAccessMixin, LoginRequiredMixin, DetailView
context["can_share_document"] = user_can_share(self.request.user) and can_edit context["can_share_document"] = user_can_share(self.request.user) and can_edit
context["can_change_visibility"] = user_can_change_visibility(self.request.user) 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)) existing_tag_ids = list(document.tags.values_list("pk", flat=True))
context["available_tags"] = DocumentTag.objects.exclude( context["available_tags"] = DocumentTag.objects.exclude(
pk__in=existing_tag_ids pk__in=existing_tag_ids
@ -1567,6 +1551,62 @@ class SearchObjectsView(LoginRequiredMixin, View):
return JsonResponse({"results": results}) 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 pdf2image import convert_from_path, pdfinfo_from_path
from io import BytesIO from io import BytesIO
from django.core.cache import cache from django.core.cache import cache