feat: add bulk user selection for sharing, implement hierarchical path display, and standardize user naming conventions.

This commit is contained in:
kdeterme 2026-08-29 19:33:02 +02:00
parent a098a66204
commit 15b058e246
8 changed files with 362 additions and 88 deletions

View file

@ -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

View file

@ -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 = {

View file

@ -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)

View file

@ -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:

View file

@ -130,7 +130,7 @@
<select name="folder_id" id="id_add_folder_select" class="form-select" required>
<option value="">-- {% translate "Select a folder" %} --</option>
{% for f in available_folders %}
<option value="{{ f.pk }}">{{ f.name }}</option>
<option value="{{ f.pk }}">{{ f.get_full_path }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary">{% translate "Add" %}</button>
@ -440,12 +440,14 @@
<div class="alert alert-danger">{{ share_form.non_field_errors }}</div>
{% endif %}
<div class="mb-2">
{{ share_form.user }}
{% if share_form.user.errors %}
<div class="text-danger small">{{ share_form.user.errors }}</div>
<label class="form-label small fw-semibold" for="{{ share_form.users.id_for_label }}">{% translate "Users" %}</label>
{{ share_form.users }}
{% if share_form.users.errors %}
<div class="text-danger small">{{ share_form.users.errors }}</div>
{% endif %}
</div>
<div class="mb-2">
<label class="form-label small fw-semibold" for="{{ share_form.permission.id_for_label }}">{% translate "Permission" %}</label>
{{ share_form.permission }}
{% if share_form.permission.errors %}
<div class="text-danger small">{{ share_form.permission.errors }}</div>
@ -456,7 +458,7 @@
<ul class="list-group list-group-flush">
{% for share in document.shares.all %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>{{ share.user|short_name }} – {{ share.get_permission_display }}</span>
<span>{{ share.user|user_display }} – {{ share.get_permission_display }}</span>
<form method="post" action="{% url 'documents:delete_share' document.pk share.pk %}">
{% csrf_token %}
<button class="btn btn-sm btn-outline-danger" type="submit"><i class="bi bi-x"></i></button>
@ -759,7 +761,7 @@ document.addEventListener('DOMContentLoaded', function () {
<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>
{% for f in all_accessible_folders %}
<option value="{{ f.pk }}" {% if f in document.folders.all %}selected{% endif %}>{{ f.name }}</option>
<option value="{{ f.pk }}" {% if f in document.folders.all %}selected{% endif %}>{{ f.get_full_path }}</option>
{% endfor %}
</select>
</div>
@ -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');

View file

@ -1,5 +1,10 @@
{% extends "documents/base_documents.html" %}
{% load i18n short_name %}
{% load i18n static short_name %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'common/select2.min.css' %}" />
<link rel="stylesheet" href="{% static 'common/select2-bootstrap-5-theme.min.css' %}" />
{% endblock %}
{% block content %}
<div class="container py-4">
@ -42,10 +47,10 @@
<form method="post" action="{% url 'documents:folder_share_add' folder.pk %}">
{% csrf_token %}
<div class="mb-3">
<label class="form-label" for="{{ share_form.user.id_for_label }}">{% translate "User" %}</label>
{{ share_form.user }}
{% if share_form.user.errors %}
<div class="invalid-feedback d-block">{{ share_form.user.errors|join:', ' }}</div>
<label class="form-label" for="{{ share_form.users.id_for_label }}">{% translate "Users" %}</label>
{{ share_form.users }}
{% if share_form.users.errors %}
<div class="invalid-feedback d-block">{{ share_form.users.errors|join:', ' }}</div>
{% endif %}
</div>
<div class="mb-3">
@ -63,7 +68,7 @@
<ul class="list-group list-group-flush">
{% for share in shares %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>{{ share.user|short_name }} – {{ share.get_permission_display }}</span>
<span>{{ share.user|user_display }} – {{ share.get_permission_display }}</span>
<form method="post" action="{% url 'documents:folder_share_delete' folder.pk share.pk %}" class="ms-2">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger">
@ -144,4 +149,21 @@
</div>
</div>
</div>
{% endblock %}
{% block script %}
{{ block.super }}
<script src="{% static 'common/select2.min.js' %}"></script>
<script>
$(document).ready(function() {
if ($('#id_folder_share_users').length) {
$('#id_folder_share_users').select2({
theme: 'bootstrap-5',
placeholder: "{% translate 'Rechercher et sélectionner des utilisateurs...' %}",
allowClear: true,
width: '100%'
});
}
});
</script>
{% endblock %}

View file

@ -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)

View file

@ -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(