feat: implement AJAX-based folder autocomplete for Select2 widgets and add corresponding view and tests
This commit is contained in:
parent
15b058e246
commit
32eec7b442
7 changed files with 212 additions and 51 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue