feat: consolidate document deletion logic and expose deletion UI based on granular permissions
This commit is contained in:
parent
64423b3de5
commit
2a90736430
6 changed files with 98 additions and 47 deletions
|
|
@ -159,11 +159,20 @@ def user_is_internal_manager(user) -> bool:
|
||||||
return config.roles.filter(name="manager").exists()
|
return config.roles.filter(name="manager").exists()
|
||||||
|
|
||||||
|
|
||||||
def document_has_project_or_intervention_attachment(document: ManagedDocument) -> bool:
|
def user_can_delete_document(
|
||||||
return (
|
user: settings.AUTH_USER_MODEL | AnonymousUser,
|
||||||
document.attachments.filter(content_type__app_label="projects", content_type__model="project").exists()
|
document: ManagedDocument,
|
||||||
or document.attachments.filter(content_type__app_label="interventions", content_type__model="intervention").exists()
|
) -> bool:
|
||||||
)
|
"""Return ``True`` if ``user`` can delete ``document``."""
|
||||||
|
if user is None or not getattr(user, "is_authenticated", False):
|
||||||
|
return False
|
||||||
|
if user_is_documents_admin(user):
|
||||||
|
return True
|
||||||
|
if document.created_by_id and document.created_by_id == getattr(user, "pk", None):
|
||||||
|
return True
|
||||||
|
return user_has_document_permission(user, document, required_permission=ManagedDocument.PERMISSION_EDIT)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _get_accessible_thematic_ids(user) -> set[int]:
|
def _get_accessible_thematic_ids(user) -> set[int]:
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@
|
||||||
<button type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#geolocModeModal">
|
<button type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#geolocModeModal">
|
||||||
<i class="bi bi-geo-alt"></i> {% translate "Géolocaliser" %}
|
<i class="bi bi-geo-alt"></i> {% translate "Géolocaliser" %}
|
||||||
</button>
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_delete_document %}
|
||||||
<a class="btn btn-outline-danger" href="{% url 'documents:delete' document.pk %}?next={% url 'documents:list' %}"><i class="bi bi-trash"></i> {% translate "Delete" %}</a>
|
<a class="btn btn-outline-danger" href="{% url 'documents:delete' document.pk %}?next={% url 'documents:list' %}"><i class="bi bi-trash"></i> {% translate "Delete" %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,7 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
{% if document.created_by_id == request.user.id or is_documents_admin %}
|
{% if item.can_delete %}
|
||||||
<a class="btn btn-sm btn-outline-danger" style="border-style: hidden;" href="{% url 'documents:delete' document.pk %}?next={{ request.get_full_path|urlencode }}" title="{% translate 'Delete document' %}">
|
<a class="btn btn-sm btn-outline-danger" style="border-style: hidden;" href="{% url 'documents:delete' document.pk %}?next={{ request.get_full_path|urlencode }}" title="{% translate 'Delete document' %}">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -47,3 +47,19 @@ def render_folder_tree(nodes, active_slug=None, trail_slugs=None):
|
||||||
"active_slug": active_slug,
|
"active_slug": active_slug,
|
||||||
"trail_slugs": trail_slugs or [],
|
"trail_slugs": trail_slugs or [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def can_delete_document(document, user):
|
||||||
|
from documents.permissions import user_can_delete_document
|
||||||
|
if not document or not user:
|
||||||
|
return False
|
||||||
|
return user_can_delete_document(user, document)
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
def user_can_delete_document_tag(user, document):
|
||||||
|
from documents.permissions import user_can_delete_document
|
||||||
|
if not document or not user:
|
||||||
|
return False
|
||||||
|
return user_can_delete_document(user, document)
|
||||||
|
|
@ -395,36 +395,6 @@ class DocumentDeletionPermissionTests(TestCase):
|
||||||
created_by=self.owner,
|
created_by=self.owner,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_internal_manager_can_delete_project_document(self):
|
|
||||||
document = ManagedDocument.objects.create(title="Project document", created_by=self.owner)
|
|
||||||
DocumentAttachment.objects.create(
|
|
||||||
document=document,
|
|
||||||
content_type=ContentType.objects.get_for_model(Project),
|
|
||||||
object_id=self.project.pk,
|
|
||||||
)
|
|
||||||
|
|
||||||
ensure_document_deletable(document, self.manager)
|
|
||||||
|
|
||||||
def test_internal_manager_can_delete_intervention_document(self):
|
|
||||||
document = ManagedDocument.objects.create(title="Intervention document", created_by=self.owner)
|
|
||||||
DocumentAttachment.objects.create(
|
|
||||||
document=document,
|
|
||||||
content_type=ContentType.objects.get_for_model(Intervention),
|
|
||||||
object_id=self.intervention.pk,
|
|
||||||
)
|
|
||||||
|
|
||||||
ensure_document_deletable(document, self.manager)
|
|
||||||
self.client.force_login(self.manager)
|
|
||||||
response = self.client.post(
|
|
||||||
reverse("documents:folder_edit", args=[self.folder.pk]),
|
|
||||||
{
|
|
||||||
"name": "Still locked",
|
|
||||||
"description": "",
|
|
||||||
"application_label": "",
|
|
||||||
"parent_folders": [],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
|
|
||||||
def test_internal_manager_can_delete_thematic_asset_document_owned_by_others(self):
|
def test_internal_manager_can_delete_thematic_asset_document_owned_by_others(self):
|
||||||
from common.models import Thematic, UserThematics
|
from common.models import Thematic, UserThematics
|
||||||
|
|
@ -484,6 +454,65 @@ class DocumentDeletionPermissionTests(TestCase):
|
||||||
with self.assertRaises(PermissionDenied):
|
with self.assertRaises(PermissionDenied):
|
||||||
ensure_document_deletable(other_doc, self.manager)
|
ensure_document_deletable(other_doc, self.manager)
|
||||||
|
|
||||||
|
def test_document_list_view_renders_delete_button_when_user_has_permission(self):
|
||||||
|
from common.models import Thematic, UserThematics
|
||||||
|
from assets.models import TrafficLightIntersection
|
||||||
|
from documents.models import DocumentFolderAttachment
|
||||||
|
|
||||||
|
thematic, _ = Thematic.objects.get_or_create(
|
||||||
|
code="trafficlights",
|
||||||
|
defaults={"name_fr": "Signalisation Lumineuse Tricolore", "name_nl": "Verkeerslichten"}
|
||||||
|
)
|
||||||
|
UserThematics.objects.create(user_config=self.manager_config, thematic=thematic, can_edit_assets=True)
|
||||||
|
|
||||||
|
intersection = TrafficLightIntersection.objects.create(code="TL-LIST-1", name_fr="Carrefour List 1")
|
||||||
|
root_folder = DocumentFolder.objects.create(name="Root Intersection Folder List", created_by=self.owner)
|
||||||
|
|
||||||
|
DocumentFolderAttachment.objects.create(
|
||||||
|
folder=root_folder,
|
||||||
|
content_type=ContentType.objects.get_for_model(TrafficLightIntersection),
|
||||||
|
object_id=intersection.pk,
|
||||||
|
)
|
||||||
|
|
||||||
|
other_doc = ManagedDocument.objects.create(title="Other User Doc List Test", created_by=self.owner)
|
||||||
|
other_doc.folders.add(root_folder)
|
||||||
|
|
||||||
|
self.client.force_login(self.manager)
|
||||||
|
response = self.client.get(reverse("documents:list"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
delete_url = reverse("documents:delete", args=[other_doc.pk])
|
||||||
|
self.assertContains(response, delete_url)
|
||||||
|
|
||||||
|
def test_document_list_view_hides_delete_button_when_user_lacks_permission(self):
|
||||||
|
from common.models import Thematic, UserThematics
|
||||||
|
from assets.models import TrafficLightIntersection
|
||||||
|
from documents.models import DocumentFolderAttachment
|
||||||
|
|
||||||
|
thematic, _ = Thematic.objects.get_or_create(
|
||||||
|
code="trafficlights",
|
||||||
|
defaults={"name_fr": "Signalisation Lumineuse Tricolore", "name_nl": "Verkeerslichten"}
|
||||||
|
)
|
||||||
|
UserThematics.objects.create(user_config=self.manager_config, thematic=thematic, can_edit_assets=False)
|
||||||
|
|
||||||
|
intersection = TrafficLightIntersection.objects.create(code="TL-LIST-2", name_fr="Carrefour List 2")
|
||||||
|
root_folder = DocumentFolder.objects.create(name="Root Intersection Folder List 2", created_by=self.owner)
|
||||||
|
|
||||||
|
DocumentFolderAttachment.objects.create(
|
||||||
|
folder=root_folder,
|
||||||
|
content_type=ContentType.objects.get_for_model(TrafficLightIntersection),
|
||||||
|
object_id=intersection.pk,
|
||||||
|
)
|
||||||
|
|
||||||
|
other_doc = ManagedDocument.objects.create(title="Other User Doc List Test 2", created_by=self.owner)
|
||||||
|
other_doc.folders.add(root_folder)
|
||||||
|
|
||||||
|
self.client.force_login(self.manager)
|
||||||
|
response = self.client.get(reverse("documents:list"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
delete_url = reverse("documents:delete", args=[other_doc.pk])
|
||||||
|
self.assertNotContains(response, delete_url)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class SearchObjectsViewTests(TestCase):
|
class SearchObjectsViewTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|
|
||||||
|
|
@ -40,10 +40,10 @@ from .models import (
|
||||||
ManagedDocument,
|
ManagedDocument,
|
||||||
)
|
)
|
||||||
from .permissions import (
|
from .permissions import (
|
||||||
document_has_project_or_intervention_attachment,
|
|
||||||
filter_documents_for_user,
|
filter_documents_for_user,
|
||||||
filter_folders_for_user,
|
filter_folders_for_user,
|
||||||
get_user_visible_folder_ids,
|
get_user_visible_folder_ids,
|
||||||
|
user_can_delete_document,
|
||||||
user_is_internal_manager,
|
user_is_internal_manager,
|
||||||
user_has_document_permission,
|
user_has_document_permission,
|
||||||
user_is_documents_admin,
|
user_is_documents_admin,
|
||||||
|
|
@ -224,14 +224,7 @@ def ensure_folder_editable(folder, user):
|
||||||
|
|
||||||
def ensure_document_deletable(document, user):
|
def ensure_document_deletable(document, user):
|
||||||
"""Vérifie si l'utilisateur peut supprimer le document."""
|
"""Vérifie si l'utilisateur peut supprimer le document."""
|
||||||
if user_is_documents_admin(user):
|
if not user_can_delete_document(user, document):
|
||||||
return
|
|
||||||
if user_is_internal_manager(user) and document_has_project_or_intervention_attachment(document):
|
|
||||||
return
|
|
||||||
if document.created_by_id and document.created_by_id == getattr(user, "pk", None):
|
|
||||||
return
|
|
||||||
if user_has_document_permission(user, document, required_permission=ManagedDocument.PERMISSION_EDIT):
|
|
||||||
return
|
|
||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -432,6 +425,7 @@ class DocumentListView(DocumentsAppAccessMixin, LoginRequiredMixin, ListView):
|
||||||
{
|
{
|
||||||
"type": "document",
|
"type": "document",
|
||||||
"object": document,
|
"object": document,
|
||||||
|
"can_delete": user_can_delete_document(self.request.user, document),
|
||||||
"updated_at": document.updated_at,
|
"updated_at": document.updated_at,
|
||||||
"updated_by": document.latest_version.uploaded_by.get_short_name() if document.latest_version and document.latest_version.uploaded_by else "/",
|
"updated_by": document.latest_version.uploaded_by.get_short_name() if document.latest_version and document.latest_version.uploaded_by else "/",
|
||||||
"name_key": document.title.casefold(),
|
"name_key": document.title.casefold(),
|
||||||
|
|
@ -1069,6 +1063,7 @@ class DocumentDetailView(DocumentsAppAccessMixin, LoginRequiredMixin, DetailView
|
||||||
document,
|
document,
|
||||||
required_permission=ManagedDocument.PERMISSION_EDIT,
|
required_permission=ManagedDocument.PERMISSION_EDIT,
|
||||||
)
|
)
|
||||||
|
context["can_delete_document"] = user_can_delete_document(self.request.user, document)
|
||||||
|
|
||||||
# Available folders/tags for add forms (exclude already associated ones)
|
# Available folders/tags for add forms (exclude already associated ones)
|
||||||
existing_folder_ids = list(document.folders.values_list("pk", flat=True))
|
existing_folder_ids = list(document.folders.values_list("pk", flat=True))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue