diff --git a/loko/common/templatetags/short_name.py b/loko/common/templatetags/short_name.py index 28215f0..859ad62 100644 --- a/loko/common/templatetags/short_name.py +++ b/loko/common/templatetags/short_name.py @@ -1,20 +1,16 @@ from django import template import os +from common.utils import get_short_name, get_user_company, get_user_display_name register = template.Library() + @register.filter def short_name(user): """ Retourne 'Prénom N.' à partir d'un objet User. """ - if not user: - return "" - first = getattr(user, "first_name", "") or "" - last = getattr(user, "last_name", "") or "" - initial = (last[0].upper() + ".") if last else "" - res = f"{first} {initial}".strip() - return res if res else getattr(user, "username", "") + return get_short_name(user) @register.filter @@ -22,20 +18,16 @@ def user_company(user): """ Retourne le nom de la société de l'utilisateur. """ - if not user: - return "" - if hasattr(user, 'config') and user.config and user.config.company: - return user.config.company.name - try: - from contracts.models import CompanyMember - member = CompanyMember.objects.filter(user=user).prefetch_related('teams__company').first() - if member: - for team in member.teams.all(): - if team.company: - return team.company.name - except Exception: - pass - return "" + return get_user_company(user) + + +@register.filter +def user_display(user): + """ + Retourne 'Prénom N. (Organisme)' ou 'Prénom N.' si aucun organisme n'est associé. + """ + return get_user_display_name(user) + @register.filter diff --git a/loko/common/utils.py b/loko/common/utils.py index 344f6f8..a5ebfdd 100644 --- a/loko/common/utils.py +++ b/loko/common/utils.py @@ -126,6 +126,42 @@ def get_short_name(user): return res if res else getattr(user, "username", "") +def get_user_company(user): + """ + Retourne le nom de l'organisme / société de l'utilisateur. + """ + if not user: + return "" + if hasattr(user, "config") and user.config and user.config.company: + return user.config.company.name + try: + from contracts.models import CompanyMember + member = CompanyMember.objects.filter(user=user).prefetch_related("teams__company").first() + if member: + for team in member.teams.all(): + if team.company: + return team.company.name + except Exception: + pass + return "" + + +def get_user_display_name(user, include_company=True): + """ + Retourne 'Prénom N. (Organisme)' ou 'Prénom N.' + """ + if not user: + return "" + name = get_short_name(user) + if not include_company: + return name + company = get_user_company(user) + if company: + return f"{name} ({company})" + return name + + + from django.utils.translation import gettext_lazy as _ REGIONAL_TERMINOLOGY = { diff --git a/loko/documents/forms.py b/loko/documents/forms.py index d2bd38d..ccb1615 100644 --- a/loko/documents/forms.py +++ b/loko/documents/forms.py @@ -23,7 +23,7 @@ from .permissions import ( user_can_change_visibility, ) from common.models import Thematic -from common.utils import get_short_name +from common.utils import get_short_name, get_user_display_name User = get_user_model() @@ -66,11 +66,15 @@ class DocumentUpdateForm(forms.ModelForm): self.fields["folders"].widget.attrs.setdefault("class", "form-select") self.fields["folders"].required = False if self.user: - self.fields["folders"].queryset = filter_folders_for_user( - DocumentFolder.objects.all(), + accessible_folders = filter_folders_for_user( + DocumentFolder.objects.prefetch_related("parent_folders"), self.user, required_permission=ManagedDocument.PERMISSION_VIEW, - ).order_by("name") + ) + 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() def clean_visibility(self): if self.user and not user_can_change_visibility(self.user): @@ -108,11 +112,14 @@ class ManagedDocumentForm(forms.ModelForm): # Limiter les folders accessibles à l'utilisateur if user: accessible_folders = filter_folders_for_user( - DocumentFolder.objects.all(), + DocumentFolder.objects.prefetch_related("parent_folders"), user, required_permission=ManagedDocument.PERMISSION_VIEW ) - self.fields["folders"].queryset = accessible_folders.order_by("name") + 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() # Pré-sélectionner le folder initial si fourni if initial_folder and not self.instance.pk: @@ -174,60 +181,136 @@ class DocumentVersionForm(forms.ModelForm): self.fields["comment"].widget.attrs.setdefault("class", "form-control") -class DocumentShareForm(forms.ModelForm): - class Meta: - model = DocumentShare - fields = ["user", "permission"] - widgets = { - "permission": forms.Select(attrs={"class": "form-select"}), - } +class DocumentShareForm(forms.Form): + users = forms.ModelMultipleChoiceField( + queryset=User.objects.none(), + label=_("Users"), + widget=forms.SelectMultiple( + attrs={ + "class": "form-select select2-users", + "id": "id_share_users", + "style": "width: 100%;", + } + ), + ) + permission = forms.ChoiceField( + choices=ManagedDocument.PERMISSION_CHOICES, + initial=ManagedDocument.PERMISSION_VIEW, + label=_("Permission"), + widget=forms.Select(attrs={"class": "form-select"}), + ) def __init__(self, *args, **kwargs): document = kwargs.pop("document", None) + if args and isinstance(args[0], dict): + data = args[0].copy() + if "user" in data and "users" not in data: + val = data.getlist("user") if hasattr(data, "getlist") else [data["user"]] + if hasattr(data, "setlist"): + data.setlist("users", val) + else: + data["users"] = val + args = (data,) + args[1:] + elif kwargs.get("data"): + data = kwargs["data"].copy() + if "user" in data and "users" not in data: + val = data.getlist("user") if hasattr(data, "getlist") else [data["user"]] + if hasattr(data, "setlist"): + data.setlist("users", val) + else: + data["users"] = val + kwargs["data"] = data + super().__init__(*args, **kwargs) + qs = User.objects.filter(is_active=True).select_related("config__company") if document: - self.fields["user"].queryset = User.objects.exclude(document_shares__document=document) - self.fields["user"].widget.attrs.setdefault("class", "form-select") - self.fields["user"].label_from_instance = lambda obj: get_short_name(obj) or obj.username + qs = qs.exclude(document_shares__document=document) + user_list = sorted(qs, key=lambda u: get_user_display_name(u).lower()) + self.fields["users"].queryset = qs + self.fields["users"].choices = [(u.pk, get_user_display_name(u)) for u in user_list] + self.fields["users"].label_from_instance = lambda obj: get_user_display_name(obj) - def save(self, commit=True, creator=None, document=None): - share = super().save(commit=False) - if document: - share.document = document - if creator and not share.created_by: - share.created_by = creator - if commit: - share.save() - return share + def save(self, creator=None, document=None): + users = self.cleaned_data.get("users", []) + permission = self.cleaned_data.get("permission") + shares = [] + for user in users: + share, _ = DocumentShare.objects.update_or_create( + document=document, + user=user, + defaults={ + "permission": permission, + "created_by": creator, + }, + ) + shares.append(share) + return shares -class DocumentFolderShareForm(forms.ModelForm): - class Meta: - model = DocumentFolderShare - fields = ["user", "permission"] - widgets = { - "permission": forms.Select(attrs={"class": "form-select"}), - } +class DocumentFolderShareForm(forms.Form): + users = forms.ModelMultipleChoiceField( + queryset=User.objects.none(), + label=_("Users"), + widget=forms.SelectMultiple( + attrs={ + "class": "form-select select2-users", + "id": "id_folder_share_users", + "style": "width: 100%;", + } + ), + ) + permission = forms.ChoiceField( + choices=ManagedDocument.PERMISSION_CHOICES, + initial=ManagedDocument.PERMISSION_VIEW, + label=_("Permission"), + widget=forms.Select(attrs={"class": "form-select"}), + ) def __init__(self, *args, **kwargs): folder = kwargs.pop("folder", None) + if args and isinstance(args[0], dict): + data = args[0].copy() + if "user" in data and "users" not in data: + val = data.getlist("user") if hasattr(data, "getlist") else [data["user"]] + if hasattr(data, "setlist"): + data.setlist("users", val) + else: + data["users"] = val + args = (data,) + args[1:] + elif kwargs.get("data"): + data = kwargs["data"].copy() + if "user" in data and "users" not in data: + val = data.getlist("user") if hasattr(data, "getlist") else [data["user"]] + if hasattr(data, "setlist"): + data.setlist("users", val) + else: + data["users"] = val + kwargs["data"] = data + super().__init__(*args, **kwargs) + qs = User.objects.filter(is_active=True).select_related("config__company") if folder: - self.fields["user"].queryset = User.objects.exclude( - document_folder_shares__folder=folder + qs = qs.exclude(document_folder_shares__folder=folder) + user_list = sorted(qs, key=lambda u: get_user_display_name(u).lower()) + self.fields["users"].queryset = qs + self.fields["users"].choices = [(u.pk, get_user_display_name(u)) for u in user_list] + self.fields["users"].label_from_instance = lambda obj: get_user_display_name(obj) + + def save(self, creator=None, folder=None): + users = self.cleaned_data.get("users", []) + permission = self.cleaned_data.get("permission") + shares = [] + for user in users: + share, _ = DocumentFolderShare.objects.update_or_create( + folder=folder, + user=user, + defaults={ + "permission": permission, + "created_by": creator, + }, ) - self.fields["user"].widget.attrs.setdefault("class", "form-select") - self.fields["user"].label_from_instance = lambda obj: get_short_name(obj) or obj.username - - def save(self, commit=True, creator=None, folder=None): - share = super().save(commit=False) - if folder: - share.folder = folder - if creator and not share.created_by: - share.created_by = creator - if commit: - share.save() - return share + shares.append(share) + return shares class DocumentValidationForm(forms.ModelForm): @@ -397,11 +480,15 @@ class DocumentFolderForm(forms.ModelForm): instance = getattr(self, "instance", None) if instance and instance.pk: excluded_ids = {instance.pk, *self._get_descendant_ids(instance)} - self.fields["parent_folders"].queryset = ( - DocumentFolder.objects.exclude(pk__in=excluded_ids).order_by("name") + parent_qs = ( + DocumentFolder.objects.prefetch_related("parent_folders").exclude(pk__in=excluded_ids) ) else: - self.fields["parent_folders"].queryset = DocumentFolder.objects.order_by("name") + parent_qs = DocumentFolder.objects.prefetch_related("parent_folders") + sorted_parents = sorted(parent_qs, key=lambda f: f.get_full_path().lower()) + 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: self.fields["thematics"].queryset = Thematic.objects.order_by("name_fr") @@ -415,8 +502,11 @@ class DocumentFolderForm(forms.ModelForm): if "created_by" in self.fields: self.fields["created_by"].label = _("Owner") self.fields["created_by"].required = False - self.fields["created_by"].queryset = User.objects.order_by("username") - self.fields["created_by"].label_from_instance = lambda obj: get_short_name(obj) or obj.username + user_qs = User.objects.filter(is_active=True).select_related("config__company") + user_list = sorted(user_qs, key=lambda u: get_user_display_name(u).lower()) + self.fields["created_by"].queryset = user_qs + self.fields["created_by"].choices = [(u.pk, get_user_display_name(u)) for u in user_list] + self.fields["created_by"].label_from_instance = lambda obj: get_user_display_name(obj) or obj.username if not self._can_edit_owner(): self.fields.pop("created_by", None) diff --git a/loko/documents/models.py b/loko/documents/models.py index 095f886..df87fb0 100644 --- a/loko/documents/models.py +++ b/loko/documents/models.py @@ -167,8 +167,37 @@ class DocumentFolder(models.Model): models.Index(fields=["slug"]), ] + def get_full_path(self, separator=" / "): + """ + Retourne le chemin complet du dossier depuis la racine (ex: 'Anderlecht / 25-XPERT / As-Built'). + """ + if not self.name: + return "" + parts = [self.name] + visited = {self.pk} if self.pk else set() + current = self + while current and current.pk: + try: + parents = list(current.parent_folders.all()) + except Exception: + break + if not parents: + break + parents.sort(key=lambda p: (p.name or "").lower()) + parent = parents[0] + if not parent.pk or parent.pk in visited: + break + visited.add(parent.pk) + parts.append(parent.name) + current = parent + return separator.join(reversed(parts)) + + @property + def full_path(self): + return self.get_full_path() + def __str__(self): - return self.name + return self.get_full_path() def save(self, *args, **kwargs): if not self.slug: diff --git a/loko/documents/templates/documents/document_detail.html b/loko/documents/templates/documents/document_detail.html index 7015bd2..9d792b2 100644 --- a/loko/documents/templates/documents/document_detail.html +++ b/loko/documents/templates/documents/document_detail.html @@ -130,7 +130,7 @@ @@ -440,12 +440,14 @@
{{ share_form.non_field_errors }}
{% endif %}
- {{ share_form.user }} - {% if share_form.user.errors %} -
{{ share_form.user.errors }}
+ + {{ share_form.users }} + {% if share_form.users.errors %} +
{{ share_form.users.errors }}
{% endif %}
+ {{ share_form.permission }} {% if share_form.permission.errors %}
{{ share_form.permission.errors }}
@@ -456,7 +458,7 @@
@@ -811,6 +813,16 @@ document.addEventListener('DOMContentLoaded', function () { }); } + // Select2 for document share users + if ($('#id_share_users').length) { + $('#id_share_users').select2({ + theme: 'bootstrap-5', + placeholder: "{% translate 'Rechercher et sélectionner des utilisateurs...' %}", + allowClear: true, + width: '100%' + }); + } + const $contentType = $('#id_content_type'); const $objectId = $('#id_object_id'); const $searchInput = $('#object_search_input'); diff --git a/loko/documents/templates/documents/documentfolder_share.html b/loko/documents/templates/documents/documentfolder_share.html index c7744dd..ef1f77f 100644 --- a/loko/documents/templates/documents/documentfolder_share.html +++ b/loko/documents/templates/documents/documentfolder_share.html @@ -1,5 +1,10 @@ {% extends "documents/base_documents.html" %} -{% load i18n short_name %} +{% load i18n static short_name %} + +{% block extra_css %} + + +{% endblock %} {% block content %}
@@ -42,10 +47,10 @@ {% csrf_token %}
- - {{ share_form.user }} - {% if share_form.user.errors %} -
{{ share_form.user.errors|join:', ' }}
+ + {{ share_form.users }} + {% if share_form.users.errors %} +
{{ share_form.users.errors|join:', ' }}
{% endif %}
@@ -63,7 +68,7 @@
+{% endblock %} + +{% block script %} + {{ block.super }} + + {% endblock %} \ No newline at end of file diff --git a/loko/documents/tests.py b/loko/documents/tests.py index 138b10f..6ca5998 100644 --- a/loko/documents/tests.py +++ b/loko/documents/tests.py @@ -1074,4 +1074,91 @@ class DocumentVisibilityAndIndependenceTests(TestCase): ) self.assertEqual(link_response.status_code, 403) + def test_folder_full_path_hierarchy(self): + from .forms import ManagedDocumentForm, DocumentFolderForm + + commune = DocumentFolder.objects.create(name="Anderlecht", slug="anderlecht", created_by=self.creator) + carrefour = DocumentFolder.objects.create(name="Carrefour 1", slug="carrefour-1", created_by=self.creator) + carrefour.parent_folders.add(commune) + asbuilt = DocumentFolder.objects.create(name="As-Built", slug="asbuilt-1", created_by=self.creator) + asbuilt.parent_folders.add(carrefour) + + # Test get_full_path on models + self.assertEqual(commune.get_full_path(), "Anderlecht") + self.assertEqual(carrefour.get_full_path(), "Anderlecht / Carrefour 1") + self.assertEqual(asbuilt.get_full_path(), "Anderlecht / Carrefour 1 / As-Built") + self.assertEqual(str(asbuilt), "Anderlecht / Carrefour 1 / As-Built") + + # Test ManagedDocumentForm choices + form = ManagedDocumentForm(user=self.creator) + folder_choices = dict(form.fields["folders"].choices) + self.assertEqual(folder_choices[asbuilt.pk], "Anderlecht / Carrefour 1 / As-Built") + + # Test DocumentFolderForm choices + folder_form = DocumentFolderForm(user=self.creator) + parent_choices = dict(folder_form.fields["parent_folders"].choices) + self.assertEqual(parent_choices[carrefour.pk], "Anderlecht / Carrefour 1") + + def test_user_display_name_with_company(self): + from common.utils import get_user_display_name + from common.models import UserConfig + from contracts.models import Company + from .models import VisibilityScope + + user_with_comp = User.objects.create_user( + username="jdupont", first_name="Jean", last_name="Dupont", password="pass" + ) + company = Company.objects.create(name="Bruxelles Mobilité") + UserConfig.objects.create(user=user_with_comp, company=company) + + user_without_comp = User.objects.create_user( + username="msmith", first_name="Mary", last_name="Smith", password="pass" + ) + + self.assertEqual(get_user_display_name(user_with_comp), "Jean D. (Bruxelles Mobilité)") + self.assertEqual(get_user_display_name(user_without_comp), "Mary S.") + + def test_multiple_users_share_folder_and_document(self): + from .forms import DocumentShareForm, DocumentFolderShareForm + from .models import VisibilityScope + + u1 = User.objects.create_user(username="target1", first_name="Alice", last_name="A", password="pass") + u2 = User.objects.create_user(username="target2", first_name="Bob", last_name="B", password="pass") + + # Test folder sharing with multiple users + folder = DocumentFolder.objects.create(name="Shared Folder", created_by=self.creator) + self.client.force_login(self.creator) + + response = self.client.post( + reverse("documents:folder_share_add", args=[folder.pk]), + { + "users": [u1.pk, u2.pk], + "permission": ManagedDocument.PERMISSION_VIEW, + }, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(folder.shares.count(), 2) + self.assertTrue(folder.shares.filter(user=u1).exists()) + self.assertTrue(folder.shares.filter(user=u2).exists()) + + # Test document sharing with multiple users + doc = ManagedDocument.objects.create( + title="Shared Doc", + created_by=self.creator, + visibility=VisibilityScope.INTERNAL, + ) + doc_share_response = self.client.post( + reverse("documents:share", args=[doc.pk]), + { + "users": [u1.pk, u2.pk], + "permission": ManagedDocument.PERMISSION_EDIT, + }, + ) + self.assertEqual(doc_share_response.status_code, 302) + self.assertEqual(doc.shares.count(), 2) + self.assertEqual(doc.shares.get(user=u1).permission, ManagedDocument.PERMISSION_EDIT) + self.assertEqual(doc.shares.get(user=u2).permission, ManagedDocument.PERMISSION_EDIT) + + + \ No newline at end of file diff --git a/loko/documents/views.py b/loko/documents/views.py index e031719..a2adb94 100644 --- a/loko/documents/views.py +++ b/loko/documents/views.py @@ -1098,12 +1098,18 @@ class DocumentDetailView(DocumentsAppAccessMixin, LoginRequiredMixin, DetailView # 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.all(), + DocumentFolder.objects.prefetch_related("parent_folders"), self.request.user, required_permission=ManagedDocument.PERMISSION_VIEW, ) - context["available_folders"] = accessible_folders.exclude(pk__in=existing_folder_ids).order_by("name") - context["all_accessible_folders"] = accessible_folders.order_by("name") + 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(