From 061fb6dc62d59ef7a9365cdeb16011e2aea4e3b0 Mon Sep 17 00:00:00 2001 From: Karl Determe Date: Tue, 18 Aug 2026 14:29:00 +0200 Subject: [PATCH] feat: add document management views, URLs, and permission unit tests --- loko/documents/tests.py | 1585 ++++++++++++++++++++------------------- loko/documents/urls.py | 7 +- loko/documents/views.py | 5 + 3 files changed, 820 insertions(+), 777 deletions(-) diff --git a/loko/documents/tests.py b/loko/documents/tests.py index 2042957..21066eb 100644 --- a/loko/documents/tests.py +++ b/loko/documents/tests.py @@ -1,774 +1,811 @@ -from django.contrib.auth import get_user_model -from django.contrib.auth.models import AnonymousUser -from django.contrib.contenttypes.models import ContentType -from django.test import TestCase -from django.urls import reverse - -from common.models import Role, UserConfig -from interventions.models import Intervention -from projects.models import Project - -from .models import DocumentAttachment, DocumentFolder, DocumentFolderShare, DocumentShare, ManagedDocument -from .views import ensure_document_deletable -from .permissions import ( - filter_documents_for_user, - filter_folders_for_user, - get_user_visible_folder_ids, - user_has_document_permission, - user_is_documents_admin, -) - - -User = get_user_model() - - -class DocumentPermissionTests(TestCase): - def setUp(self): - self.owner = User.objects.create_user(username="owner", password="owner") - self.shared_user = User.objects.create_user(username="shared", password="shared") - self.folder_user = User.objects.create_user(username="folder", password="folder") - self.other_user = User.objects.create_user(username="other", password="other") - self.superuser = User.objects.create_superuser(username="admin", password="admin") - - self.document = ManagedDocument.objects.create(title="Doc", created_by=self.owner) - self.folder = DocumentFolder.objects.create( - name="Folder", slug="folder", created_by=self.owner - ) - self.parent_folder = DocumentFolder.objects.create( - name="Parent", slug="parent-folder", created_by=self.owner - ) - self.folder.parent_folders.add(self.parent_folder) - self.document.folders.add(self.folder) - - DocumentShare.objects.create( - document=self.document, - user=self.shared_user, - permission=ManagedDocument.PERMISSION_COMMENT, - ) - - DocumentFolderShare.objects.create( - folder=self.folder, - user=self.folder_user, - permission=ManagedDocument.PERMISSION_VIEW, - ) - - def test_owner_has_full_access(self): - self.assertTrue(user_has_document_permission(self.owner, self.document)) - self.assertTrue( - user_has_document_permission( - self.owner, - self.document, - required_permission=ManagedDocument.PERMISSION_APPROVE, - ) - ) - - def test_share_permission_respected(self): - self.assertTrue(user_has_document_permission(self.shared_user, self.document)) - self.assertFalse( - user_has_document_permission( - self.shared_user, - self.document, - required_permission=ManagedDocument.PERMISSION_EDIT, - ) - ) - - def test_folder_share_grants_access(self): - self.assertTrue(user_has_document_permission(self.folder_user, self.document)) - - def test_superuser_has_access(self): - self.assertTrue(user_has_document_permission(self.superuser, self.document)) - - def test_anonymous_user_has_no_access(self): - self.assertFalse(user_has_document_permission(AnonymousUser(), self.document)) - - def test_filter_documents_for_user(self): - queryset = ManagedDocument.objects.order_by("pk") - shared_ids = set(filter_documents_for_user(queryset, self.shared_user).values_list("pk", flat=True)) - folder_ids = set(filter_documents_for_user(queryset, self.folder_user).values_list("pk", flat=True)) - other_ids = set(filter_documents_for_user(queryset, self.other_user).values_list("pk", flat=True)) - - self.assertEqual(shared_ids, {self.document.pk}) - self.assertEqual(folder_ids, {self.document.pk}) - self.assertEqual(other_ids, set()) - - def test_filter_documents_for_superuser_returns_all(self): - queryset = ManagedDocument.objects.order_by("pk") - filtered = filter_documents_for_user(queryset, self.superuser) - self.assertEqual(set(filtered.values_list("pk", flat=True)), set(queryset.values_list("pk", flat=True))) - - def test_filter_folders_for_user(self): - queryset = DocumentFolder.objects.order_by("pk") - owner_ids = set(filter_folders_for_user(queryset, self.owner).values_list("pk", flat=True)) - shared_ids = set(filter_folders_for_user(queryset, self.folder_user).values_list("pk", flat=True)) - other_ids = set(filter_folders_for_user(queryset, self.other_user).values_list("pk", flat=True)) - - self.assertIn(self.folder.pk, owner_ids) - self.assertIn(self.folder.pk, shared_ids) - self.assertEqual(other_ids, set()) - - def test_visible_folder_ids_include_ancestors(self): - visible = get_user_visible_folder_ids(self.folder_user) - self.assertIn(self.folder.pk, visible) - self.assertIn(self.parent_folder.pk, visible) - - def test_admin_role_is_documents_admin_and_sees_all_folders(self): - admin_user = User.objects.create_user(username="admin_role_user", password="pwd") - admin_config = UserConfig.objects.create(user=admin_user, is_intern=True) - admin_role, _ = Role.objects.get_or_create(name="admin") - admin_config.roles.add(admin_role) - - self.assertTrue(user_is_documents_admin(admin_user)) - queryset = DocumentFolder.objects.all() - filtered = filter_folders_for_user(queryset, admin_user) - self.assertEqual(set(filtered.values_list("pk", flat=True)), set(queryset.values_list("pk", flat=True))) - - def test_top_manager_is_not_documents_admin_and_does_not_see_all_folders(self): - tm_user = User.objects.create_user(username="top_manager_user", password="pwd") - tm_config = UserConfig.objects.create(user=tm_user, is_intern=True) - tm_role, _ = Role.objects.get_or_create(name="top_manager") - tm_config.roles.add(tm_role) - - self.assertFalse(user_is_documents_admin(tm_user)) - queryset = DocumentFolder.objects.all() - filtered = filter_folders_for_user(queryset, tm_user) - # tm_user has no owned folders, shares, or thematics, so they should see no folders - self.assertEqual(set(filtered.values_list("pk", flat=True)), set()) - - - -class DocumentListViewTests(TestCase): - def setUp(self): - self.user = User.objects.create_user(username="viewer", password="viewer") - UserConfig.objects.create(user=self.user, is_intern=True) - self.client.force_login(self.user) - - self.parent = DocumentFolder.objects.create( - name="Parent", slug="parent", created_by=self.user - ) - self.child = DocumentFolder.objects.create( - name="Child", slug="child", created_by=self.user - ) - self.grandchild = DocumentFolder.objects.create( - name="Grandchild", slug="grandchild", created_by=self.user - ) - - self.child.parent_folders.add(self.parent) - self.grandchild.parent_folders.add(self.child) - - self.parent_doc = ManagedDocument.objects.create( - title="Parent document", - created_by=self.user, - ) - self.parent_doc.folders.add(self.parent) - - self.child_doc = ManagedDocument.objects.create( - title="Child note", - created_by=self.user, - ) - self.child_doc.folders.add(self.child) - - self.grandchild_doc = ManagedDocument.objects.create( - title="Grand strategy", - created_by=self.user, - ) - self.grandchild_doc.folders.add(self.grandchild) - - self.outside_folder = DocumentFolder.objects.create(name="External", slug="external") - self.outside_doc = ManagedDocument.objects.create( - title="Grand outside", - created_by=self.user, - ) - self.outside_doc.folders.add(self.outside_folder) - - def test_folder_filter_only_includes_direct_documents(self): - response = self.client.get(reverse("documents:list"), {"folder": self.parent.slug}) - self.assertEqual(response.status_code, 200) - - documents = list(response.context["documents"]) - document_ids = {document.pk for document in documents} - - self.assertSetEqual( - document_ids, - {self.parent_doc.pk}, - ) - self.assertNotIn(self.outside_doc.pk, document_ids) - - def test_search_returns_descendant_folders(self): - response = self.client.get( - reverse("documents:list"), - {"folder": self.parent.slug, "search": "Grand"}, - ) - self.assertEqual(response.status_code, 200) - - visible_folders = response.context["visible_folders"] - self.assertIn(self.grandchild, visible_folders) - self.assertNotIn(self.outside_folder, visible_folders) - self.assertNotIn(self.child, visible_folders) - - listing_items = response.context["listing_items"] - self.assertTrue( - any( - item["type"] == "folder" and item["object"].pk == self.grandchild.pk - for item in listing_items - ) - ) - - def test_search_limits_documents_to_folder_descendants(self): - response = self.client.get( - reverse("documents:list"), - {"folder": self.parent.slug, "search": "Grand"}, - ) - self.assertEqual(response.status_code, 200) - - documents = list(response.context["documents"]) - document_ids = {document.pk for document in documents} - - self.assertSetEqual(document_ids, {self.grandchild_doc.pk}) - self.assertNotIn(self.outside_doc.pk, document_ids) - - -class DocumentFolderManagementTests(TestCase): - def setUp(self): - self.owner = User.objects.create_user(username="folder-owner", password="owner") - UserConfig.objects.create(user=self.owner, is_intern=True) - self.other = User.objects.create_user(username="folder-other", password="other") - UserConfig.objects.create(user=self.other, is_intern=True) - self.admin = User.objects.create_user(username="folder-admin", password="admin") - admin_role, _ = Role.objects.get_or_create(name="admin") - admin_config = UserConfig.objects.create(user=self.admin, is_intern=True) - admin_config.roles.add(admin_role) - - self.folder = DocumentFolder.objects.create( - name="Root", - slug="root", - created_by=self.owner, - ) - - def test_owner_can_update_folder(self): - self.client.force_login(self.owner) - response = self.client.post( - reverse("documents:folder_edit", args=[self.folder.pk]), - { - "name": "Updated", - "description": "", - "application_label": "", - "parent_folders": [], - }, - ) - self.assertRedirects(response, f"{reverse('documents:list')}?folder={self.folder.slug}") - - self.folder.refresh_from_db() - self.assertEqual(self.folder.name, "Updated") - self.assertEqual(self.folder.created_by, self.owner) - - def test_non_owner_cannot_update_folder(self): - self.client.force_login(self.other) - response = self.client.post( - reverse("documents:folder_edit", args=[self.folder.pk]), - { - "name": "Blocked", - "description": "", - "application_label": "", - "parent_folders": [], - }, - ) - self.assertEqual(response.status_code, 403) - - def test_predefined_folder_cannot_be_modified(self): - self.folder.is_predefined = True - self.folder.save() - - self.client.force_login(self.owner) - - def test_admin_can_change_owner(self): - new_owner = User.objects.create_user(username="new-owner", password="new") - - self.client.force_login(self.admin) - response = self.client.post( - reverse("documents:folder_edit", args=[self.folder.pk]), - { - "name": "Admin updated", - "description": "", - "application_label": "", - "parent_folders": [], - "created_by": new_owner.pk, - }, - ) - self.assertRedirects(response, f"{reverse('documents:list')}?folder={self.folder.slug}") - - self.folder.refresh_from_db() - self.assertEqual(self.folder.created_by, new_owner) - - def test_owner_can_access_share_page(self): - self.client.force_login(self.owner) - response = self.client.get( - reverse("documents:folder_share", args=[self.folder.pk]) - ) - self.assertEqual(response.status_code, 200) - - def test_owner_can_create_share(self): - target = User.objects.create_user(username="share-target", password="pass") - self.client.force_login(self.owner) - response = self.client.post( - reverse("documents:folder_share_add", args=[self.folder.pk]), - {"user": target.pk, "permission": ManagedDocument.PERMISSION_VIEW}, - ) - self.assertRedirects( - response, reverse("documents:folder_share", args=[self.folder.pk]) - ) - self.assertTrue( - DocumentFolderShare.objects.filter(folder=self.folder, user=target).exists() - ) - - def test_owner_can_create_access_link(self): - self.client.force_login(self.owner) - response = self.client.post( - reverse("documents:folder_access_link", args=[self.folder.pk]), - {"permission": ManagedDocument.PERMISSION_VIEW}, - ) - self.assertRedirects( - response, reverse("documents:folder_share", args=[self.folder.pk]) - ) - self.assertEqual(self.folder.access_links.count(), 1) - - def test_owner_can_delete_folder(self): - self.client.force_login(self.owner) - response = self.client.post( - reverse("documents:folder_delete", args=[self.folder.pk]), - {}, - ) - self.assertRedirects(response, reverse("documents:list")) - self.assertFalse(DocumentFolder.objects.filter(pk=self.folder.pk).exists()) - - def test_non_owner_cannot_delete_folder(self): - self.client.force_login(self.other) - response = self.client.post( - reverse("documents:folder_delete", args=[self.folder.pk]), - {}, - ) - self.assertEqual(response.status_code, 403) - - def test_admin_can_delete_folder(self): - self.client.force_login(self.admin) - response = self.client.post( - reverse("documents:folder_delete", args=[self.folder.pk]), - {}, - ) - self.assertRedirects(response, reverse("documents:list")) - self.assertFalse(DocumentFolder.objects.filter(pk=self.folder.pk).exists()) - - def test_predefined_folder_cannot_be_deleted(self): - self.folder.is_predefined = True - self.folder.save() - - self.client.force_login(self.owner) - response = self.client.post( - reverse("documents:folder_delete", args=[self.folder.pk]), - {}, - ) - self.assertEqual(response.status_code, 403) - - def test_cannot_set_folder_as_child_of_descendant(self): - child = DocumentFolder.objects.create(name="Child", slug="child", created_by=self.owner) - child.parent_folders.add(self.folder) - - self.client.force_login(self.owner) - response = self.client.post( - reverse("documents:folder_edit", args=[self.folder.pk]), - { - "name": self.folder.name, - "description": "", - "application_label": "", - "parent_folders": [child.pk], - }, - ) - - self.assertEqual(response.status_code, 200) - self.assertIn("parent_folders", response.context["form"].errors) - - def test_folder_creation_assigns_owner(self): - self.client.force_login(self.other) - response = self.client.post( - reverse("documents:folder_create"), - { - "name": "New Folder", - "description": "", - "application_label": "", - "parent_folders": [], - }, - ) - created_folder = DocumentFolder.objects.get(name="New Folder") - expected_redirect = f"{reverse('documents:list')}?folder={created_folder.slug}" - self.assertRedirects(response, expected_redirect) - self.assertEqual(created_folder.created_by, self.other) - - -class DocumentDeletionPermissionTests(TestCase): - def setUp(self): - self.owner = User.objects.create_user(username="doc-owner", password="owner") - UserConfig.objects.create(user=self.owner, is_intern=True) - self.manager = User.objects.create_user(username="doc-manager", password="manager") - manager_role, _ = Role.objects.get_or_create(name="manager") - self.manager_config = UserConfig.objects.create(user=self.manager, is_intern=True) - self.manager_config.roles.add(manager_role) - - self.project = Project.objects.create(name="Project linked doc", created_by=self.owner) - self.intervention = Intervention.objects.create(title="Intervention linked doc") - self.folder = DocumentFolder.objects.create( - name="Root", - slug="root", - created_by=self.owner, - ) - - - def test_internal_manager_can_delete_thematic_asset_document_owned_by_others(self): - from common.models import Thematic, UserThematics - from assets.models import TrafficLightIntersection - from documents.models import DocumentFolderAttachment - from django.core.exceptions import PermissionDenied - - 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-TEST-1", name_fr="Carrefour Test 1") - root_folder = DocumentFolder.objects.create(name="Root Intersection Folder", created_by=self.owner) - sub_folder = DocumentFolder.objects.create(name="Subfolder Schemas", created_by=self.owner) - sub_folder.parent_folders.add(root_folder) - - 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 in Subfolder", created_by=self.owner) - other_doc.folders.add(sub_folder) - - # The manager with edit rights on trafficlights should be allowed to delete the document in the subfolder - ensure_document_deletable(other_doc, self.manager) - - def test_internal_manager_without_edit_rights_cannot_delete_thematic_asset_document_owned_by_others(self): - from common.models import Thematic, UserThematics - from assets.models import TrafficLightIntersection - from documents.models import DocumentFolderAttachment - from django.core.exceptions import PermissionDenied - - thematic, _ = Thematic.objects.get_or_create( - code="trafficlights", - defaults={"name_fr": "Signalisation Lumineuse Tricolore", "name_nl": "Verkeerslichten"} - ) - # Manager has can_edit_assets=False - UserThematics.objects.create(user_config=self.manager_config, thematic=thematic, can_edit_assets=False) - - intersection = TrafficLightIntersection.objects.create(code="TL-TEST-2", name_fr="Carrefour Test 2") - root_folder = DocumentFolder.objects.create(name="Root Intersection Folder 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 2", created_by=self.owner) - other_doc.folders.add(root_folder) - - # Deletion should raise PermissionDenied because manager lacks edit rights on the thematic - with self.assertRaises(PermissionDenied): - 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): - def setUp(self): - self.user = User.objects.create_user(username="testuser", password="testpassword") - UserConfig.objects.create(user=self.user, is_intern=True) - self.document = ManagedDocument.objects.create(title="Test Document", created_by=self.user) - self.project = Project.objects.create(name="Awesome project name", created_by=self.user) - self.content_type = ContentType.objects.get_for_model(Project) - - def test_search_objects_anonymous(self): - url = reverse("documents:search_objects", args=[self.document.pk]) - response = self.client.get(url, {"content_type": self.content_type.pk, "q": "Awesome"}) - self.assertEqual(response.status_code, 302) - - def test_search_objects_authenticated(self): - self.client.force_login(self.user) - url = reverse("documents:search_objects", args=[self.document.pk]) - response = self.client.get(url, {"content_type": self.content_type.pk, "q": "Awesome"}) - self.assertEqual(response.status_code, 200) - data = response.json() - self.assertEqual(len(data["results"]), 1) - self.assertEqual(data["results"][0]["id"], self.project.pk) - self.assertIn("Awesome project name", data["results"][0]["text"]) - - -class DocumentGeoreferenceTests(TestCase): - def setUp(self): - self.user = User.objects.create_user(username="geo_user", password="password") - UserConfig.objects.create(user=self.user, is_intern=True) - self.document = ManagedDocument.objects.create(title="Geo Doc", created_by=self.user) - - def test_georeference_view_denied_for_anonymous(self): - url = reverse("documents:georeference", args=[self.document.pk]) - response = self.client.get(url) - self.assertEqual(response.status_code, 302) # Redirect to login - - def test_georeference_view_allowed_for_owner(self): - self.client.force_login(self.user) - url = reverse("documents:georeference", args=[self.document.pk]) - response = self.client.get(url, {"mode": "overlay"}) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "Superposition") - - def test_georeference_save_success(self): - self.client.force_login(self.user) - url = reverse("documents:georeference_save", args=[self.document.pk]) - coords = [[4.1, 50.1], [4.2, 50.1], [4.2, 50.2], [4.1, 50.2]] - - response = self.client.post( - url, - {"coordinates": coords, "page": 1}, - content_type="application/json" - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["status"], "success") - - # Verify model updated - self.document.refresh_from_db() - self.assertEqual(self.document.georeference_coords, coords) - self.assertEqual(self.document.georeference_page, 1) - - def test_georeference_save_invalid_format(self): - self.client.force_login(self.user) - url = reverse("documents:georeference_save", args=[self.document.pk]) - - # 3 points instead of 4 - coords = [[4.1, 50.1], [4.2, 50.1], [4.2, 50.2]] - response = self.client.post( - url, - {"coordinates": coords}, - content_type="application/json" - ) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["status"], "error") - - def test_map_view_not_georeferenced(self): - self.client.force_login(self.user) - url = reverse("documents:map_view", args=[self.document.pk]) - response = self.client.get(url) - self.assertEqual(response.status_code, 404) - - def test_map_view_georeferenced(self): - self.client.force_login(self.user) - self.document.georeference_coords = [[4.1, 50.1], [4.2, 50.1], [4.2, 50.2], [4.1, 50.2]] - self.document.save() - - url = reverse("documents:map_view", args=[self.document.pk]) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - -from unittest.mock import patch -from django.core.files.uploadedfile import SimpleUploadedFile -from documents.models import DocumentVersion -import os - -class DocumentCadPreviewTests(TestCase): - def setUp(self): - self.user = User.objects.create_user(username="cad_user", password="password") - UserConfig.objects.create(user=self.user, is_intern=True) - self.document = ManagedDocument.objects.create(title="Cad Doc", created_by=self.user) - # Create a version with a mock DXF file - self.version = DocumentVersion.objects.create( - document=self.document, - file=SimpleUploadedFile("plan.dxf", b"DUMMY DXF CONTENT"), - uploaded_by=self.user, - version_number=1 - ) - - @patch("documents.utils.render_cad_file_to_image") - def test_dxf_preview_rendering_and_serving(self, mock_render): - # We simulate the render_cad_file_to_image writing a mock file - def fake_render(input_path, output_path): - with open(output_path, "wb") as f: - f.write(b"MOCK PNG CONTENT") - mock_render.side_effect = fake_render - - self.client.force_login(self.user) - url = reverse("documents:page_image", args=[self.document.pk, 1]) - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response["Content-Type"], "image/png") - self.assertEqual(b"".join(response.streaming_content), b"MOCK PNG CONTENT") - - # Verify preview file was created on disk - preview_path = self.version.file.path + ".preview.png" - self.assertTrue(os.path.exists(preview_path)) - - # Clean up preview file after test - if os.path.exists(preview_path): - os.remove(preview_path) - - @patch("documents.utils.render_cad_file_to_image") - def test_dwg_preview_rendering_and_serving(self, mock_render): - # Create a document and version with .dwg file - doc_dwg = ManagedDocument.objects.create(title="Dwg Doc", created_by=self.user) - version_dwg = DocumentVersion.objects.create( - document=doc_dwg, - file=SimpleUploadedFile("plan.dwg", b"DUMMY DWG CONTENT"), - uploaded_by=self.user, - version_number=1 - ) - - def fake_render(input_path, output_path): - with open(output_path, "wb") as f: - f.write(b"MOCK DWG PNG CONTENT") - mock_render.side_effect = fake_render - - self.client.force_login(self.user) - url = reverse("documents:page_image", args=[doc_dwg.pk, 1]) - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response["Content-Type"], "image/png") - self.assertEqual(b"".join(response.streaming_content), b"MOCK DWG PNG CONTENT") - - # Clean up preview file after test - preview_path = version_dwg.file.path + ".preview.png" - if os.path.exists(preview_path): - os.remove(preview_path) - - -from assets.models import NatureLocation -from documents.models import DocumentFolderAttachment - -class DocumentGeoreferenceAutoZoomTests(TestCase): - def setUp(self): - self.user = User.objects.create_user(username="geo_user", password="password") - UserConfig.objects.create(user=self.user, is_intern=True) - self.document = ManagedDocument.objects.create( - title="Auto Zoom Doc", - created_by=self.user - ) - self.location = NatureLocation.objects.create( - code="LOC1", - name_fr="Location 1", - lon=4.3517, - lat=50.8503 - ) - - def test_direct_attachment_coords(self): - # Create direct attachment to the geolocated asset - DocumentAttachment.objects.create( - document=self.document, - content_type=ContentType.objects.get_for_model(self.location), - object_id=self.location.pk - ) - - # Test helper function - from .views import _get_associated_asset_coords - coords = _get_associated_asset_coords(self.document) - self.assertEqual(coords, [4.3517, 50.8503]) - - # Test that view injects associated_coords into template context - self.client.force_login(self.user) - url = reverse("documents:georeference", args=[self.document.pk]) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context["associated_coords"], [4.3517, 50.8503]) - - def test_folder_attachment_coords(self): - # Create folder and associate with document - folder = DocumentFolder.objects.create(name="Geo Folder", slug="geo-folder") - self.document.folders.add(folder) - - # Create folder attachment to the geolocated asset - DocumentFolderAttachment.objects.create( - folder=folder, - content_type=ContentType.objects.get_for_model(self.location), - object_id=self.location.pk - ) - - from .views import _get_associated_asset_coords - coords = _get_associated_asset_coords(self.document) - self.assertEqual(coords, [4.3517, 50.8503]) - - def test_parent_folder_attachment_coords(self): - # Create parent folder, child folder, and associate with document - parent_folder = DocumentFolder.objects.create(name="Parent Geo Folder", slug="parent-geo-folder") - child_folder = DocumentFolder.objects.create(name="Child Folder", slug="child-folder") - child_folder.parent_folders.add(parent_folder) - self.document.folders.add(child_folder) - - # Create folder attachment to the parent folder - DocumentFolderAttachment.objects.create( - folder=parent_folder, - content_type=ContentType.objects.get_for_model(self.location), - object_id=self.location.pk - ) - - from .views import _get_associated_asset_coords - coords = _get_associated_asset_coords(self.document) - self.assertEqual(coords, [4.3517, 50.8503]) \ No newline at end of file +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase +from django.urls import reverse + +from common.models import Role, UserConfig +from interventions.models import Intervention +from projects.models import Project + +from .models import ( + DocumentAccessLink, + DocumentAttachment, + DocumentFolder, + DocumentFolderAccessLink, + DocumentFolderAttachment, + DocumentFolderShare, + DocumentShare, + DocumentVersion, + ManagedDocument, +) +from .views import ensure_document_deletable +from .permissions import ( + filter_documents_for_user, + filter_folders_for_user, + get_user_visible_folder_ids, + user_has_document_permission, + user_is_documents_admin, +) + + +User = get_user_model() + + +class DocumentPermissionTests(TestCase): + def setUp(self): + self.owner = User.objects.create_user(username="owner", password="owner") + self.shared_user = User.objects.create_user(username="shared", password="shared") + self.folder_user = User.objects.create_user(username="folder", password="folder") + self.other_user = User.objects.create_user(username="other", password="other") + self.superuser = User.objects.create_superuser(username="admin", password="admin") + + self.document = ManagedDocument.objects.create(title="Doc", created_by=self.owner) + self.folder = DocumentFolder.objects.create( + name="Folder", slug="folder", created_by=self.owner + ) + self.parent_folder = DocumentFolder.objects.create( + name="Parent", slug="parent-folder", created_by=self.owner + ) + self.folder.parent_folders.add(self.parent_folder) + self.document.folders.add(self.folder) + + DocumentShare.objects.create( + document=self.document, + user=self.shared_user, + permission=ManagedDocument.PERMISSION_COMMENT, + ) + + DocumentFolderShare.objects.create( + folder=self.folder, + user=self.folder_user, + permission=ManagedDocument.PERMISSION_VIEW, + ) + + def test_owner_has_full_access(self): + self.assertTrue(user_has_document_permission(self.owner, self.document)) + self.assertTrue( + user_has_document_permission( + self.owner, + self.document, + required_permission=ManagedDocument.PERMISSION_APPROVE, + ) + ) + + def test_share_permission_respected(self): + self.assertTrue(user_has_document_permission(self.shared_user, self.document)) + self.assertFalse( + user_has_document_permission( + self.shared_user, + self.document, + required_permission=ManagedDocument.PERMISSION_EDIT, + ) + ) + + def test_folder_share_grants_access(self): + self.assertTrue(user_has_document_permission(self.folder_user, self.document)) + + def test_superuser_has_access(self): + self.assertTrue(user_has_document_permission(self.superuser, self.document)) + + def test_anonymous_user_has_no_access(self): + self.assertFalse(user_has_document_permission(AnonymousUser(), self.document)) + + def test_filter_documents_for_user(self): + queryset = ManagedDocument.objects.order_by("pk") + shared_ids = set(filter_documents_for_user(queryset, self.shared_user).values_list("pk", flat=True)) + folder_ids = set(filter_documents_for_user(queryset, self.folder_user).values_list("pk", flat=True)) + other_ids = set(filter_documents_for_user(queryset, self.other_user).values_list("pk", flat=True)) + + self.assertEqual(shared_ids, {self.document.pk}) + self.assertEqual(folder_ids, {self.document.pk}) + self.assertEqual(other_ids, set()) + + def test_filter_documents_for_superuser_returns_all(self): + queryset = ManagedDocument.objects.order_by("pk") + filtered = filter_documents_for_user(queryset, self.superuser) + self.assertEqual(set(filtered.values_list("pk", flat=True)), set(queryset.values_list("pk", flat=True))) + + def test_filter_folders_for_user(self): + queryset = DocumentFolder.objects.order_by("pk") + owner_ids = set(filter_folders_for_user(queryset, self.owner).values_list("pk", flat=True)) + shared_ids = set(filter_folders_for_user(queryset, self.folder_user).values_list("pk", flat=True)) + other_ids = set(filter_folders_for_user(queryset, self.other_user).values_list("pk", flat=True)) + + self.assertIn(self.folder.pk, owner_ids) + self.assertIn(self.folder.pk, shared_ids) + self.assertEqual(other_ids, set()) + + def test_visible_folder_ids_include_ancestors(self): + visible = get_user_visible_folder_ids(self.folder_user) + self.assertIn(self.folder.pk, visible) + self.assertIn(self.parent_folder.pk, visible) + + def test_admin_role_is_documents_admin_and_sees_all_folders(self): + admin_user = User.objects.create_user(username="admin_role_user", password="pwd") + admin_config = UserConfig.objects.create(user=admin_user, is_intern=True) + admin_role, _ = Role.objects.get_or_create(name="admin") + admin_config.roles.add(admin_role) + + self.assertTrue(user_is_documents_admin(admin_user)) + queryset = DocumentFolder.objects.all() + filtered = filter_folders_for_user(queryset, admin_user) + self.assertEqual(set(filtered.values_list("pk", flat=True)), set(queryset.values_list("pk", flat=True))) + + def test_top_manager_is_not_documents_admin_and_does_not_see_all_folders(self): + tm_user = User.objects.create_user(username="top_manager_user", password="pwd") + tm_config = UserConfig.objects.create(user=tm_user, is_intern=True) + tm_role, _ = Role.objects.get_or_create(name="top_manager") + tm_config.roles.add(tm_role) + + self.assertFalse(user_is_documents_admin(tm_user)) + queryset = DocumentFolder.objects.all() + filtered = filter_folders_for_user(queryset, tm_user) + # tm_user has no owned folders, shares, or thematics, so they should see no folders + self.assertEqual(set(filtered.values_list("pk", flat=True)), set()) + + + +class DocumentListViewTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="viewer", password="viewer") + UserConfig.objects.create(user=self.user, is_intern=True) + self.client.force_login(self.user) + + self.parent = DocumentFolder.objects.create( + name="Parent", slug="parent", created_by=self.user + ) + self.child = DocumentFolder.objects.create( + name="Child", slug="child", created_by=self.user + ) + self.grandchild = DocumentFolder.objects.create( + name="Grandchild", slug="grandchild", created_by=self.user + ) + + self.child.parent_folders.add(self.parent) + self.grandchild.parent_folders.add(self.child) + + self.parent_doc = ManagedDocument.objects.create( + title="Parent document", + created_by=self.user, + ) + self.parent_doc.folders.add(self.parent) + + self.child_doc = ManagedDocument.objects.create( + title="Child note", + created_by=self.user, + ) + self.child_doc.folders.add(self.child) + + self.grandchild_doc = ManagedDocument.objects.create( + title="Grand strategy", + created_by=self.user, + ) + self.grandchild_doc.folders.add(self.grandchild) + + self.outside_folder = DocumentFolder.objects.create(name="External", slug="external") + self.outside_doc = ManagedDocument.objects.create( + title="Grand outside", + created_by=self.user, + ) + self.outside_doc.folders.add(self.outside_folder) + + def test_folder_filter_only_includes_direct_documents(self): + response = self.client.get(reverse("documents:list"), {"folder": self.parent.slug}) + self.assertEqual(response.status_code, 200) + + documents = list(response.context["documents"]) + document_ids = {document.pk for document in documents} + + self.assertSetEqual( + document_ids, + {self.parent_doc.pk}, + ) + self.assertNotIn(self.outside_doc.pk, document_ids) + + def test_search_returns_descendant_folders(self): + response = self.client.get( + reverse("documents:list"), + {"folder": self.parent.slug, "search": "Grand"}, + ) + self.assertEqual(response.status_code, 200) + + visible_folders = response.context["visible_folders"] + self.assertIn(self.grandchild, visible_folders) + self.assertNotIn(self.outside_folder, visible_folders) + self.assertNotIn(self.child, visible_folders) + + listing_items = response.context["listing_items"] + self.assertTrue( + any( + item["type"] == "folder" and item["object"].pk == self.grandchild.pk + for item in listing_items + ) + ) + + def test_search_limits_documents_to_folder_descendants(self): + response = self.client.get( + reverse("documents:list"), + {"folder": self.parent.slug, "search": "Grand"}, + ) + self.assertEqual(response.status_code, 200) + + documents = list(response.context["documents"]) + document_ids = {document.pk for document in documents} + + self.assertSetEqual(document_ids, {self.grandchild_doc.pk}) + self.assertNotIn(self.outside_doc.pk, document_ids) + + +class DocumentFolderManagementTests(TestCase): + def setUp(self): + self.owner = User.objects.create_user(username="folder-owner", password="owner") + UserConfig.objects.create(user=self.owner, is_intern=True) + self.other = User.objects.create_user(username="folder-other", password="other") + UserConfig.objects.create(user=self.other, is_intern=True) + self.admin = User.objects.create_user(username="folder-admin", password="admin") + admin_role, _ = Role.objects.get_or_create(name="admin") + admin_config = UserConfig.objects.create(user=self.admin, is_intern=True) + admin_config.roles.add(admin_role) + + self.folder = DocumentFolder.objects.create( + name="Root", + slug="root", + created_by=self.owner, + ) + + def test_owner_can_update_folder(self): + self.client.force_login(self.owner) + response = self.client.post( + reverse("documents:folder_edit", args=[self.folder.pk]), + { + "name": "Updated", + "description": "", + "application_label": "", + "parent_folders": [], + }, + ) + self.assertRedirects(response, f"{reverse('documents:list')}?folder={self.folder.slug}") + + self.folder.refresh_from_db() + self.assertEqual(self.folder.name, "Updated") + self.assertEqual(self.folder.created_by, self.owner) + + def test_non_owner_cannot_update_folder(self): + self.client.force_login(self.other) + response = self.client.post( + reverse("documents:folder_edit", args=[self.folder.pk]), + { + "name": "Blocked", + "description": "", + "application_label": "", + "parent_folders": [], + }, + ) + self.assertEqual(response.status_code, 403) + + def test_predefined_folder_cannot_be_modified(self): + self.folder.is_predefined = True + self.folder.save() + + self.client.force_login(self.owner) + + def test_admin_can_change_owner(self): + new_owner = User.objects.create_user(username="new-owner", password="new") + + self.client.force_login(self.admin) + response = self.client.post( + reverse("documents:folder_edit", args=[self.folder.pk]), + { + "name": "Admin updated", + "description": "", + "application_label": "", + "parent_folders": [], + "created_by": new_owner.pk, + }, + ) + self.assertRedirects(response, f"{reverse('documents:list')}?folder={self.folder.slug}") + + self.folder.refresh_from_db() + self.assertEqual(self.folder.created_by, new_owner) + + def test_owner_can_access_share_page(self): + self.client.force_login(self.owner) + response = self.client.get( + reverse("documents:folder_share", args=[self.folder.pk]) + ) + self.assertEqual(response.status_code, 200) + + def test_owner_can_create_share(self): + target = User.objects.create_user(username="share-target", password="pass") + self.client.force_login(self.owner) + response = self.client.post( + reverse("documents:folder_share_add", args=[self.folder.pk]), + {"user": target.pk, "permission": ManagedDocument.PERMISSION_VIEW}, + ) + self.assertRedirects( + response, reverse("documents:folder_share", args=[self.folder.pk]) + ) + self.assertTrue( + DocumentFolderShare.objects.filter(folder=self.folder, user=target).exists() + ) + + def test_owner_can_create_access_link(self): + self.client.force_login(self.owner) + response = self.client.post( + reverse("documents:folder_access_link", args=[self.folder.pk]), + {"permission": ManagedDocument.PERMISSION_VIEW}, + ) + self.assertRedirects( + response, reverse("documents:folder_share", args=[self.folder.pk]) + ) + self.assertEqual(self.folder.access_links.count(), 1) + + def test_owner_can_delete_folder(self): + self.client.force_login(self.owner) + response = self.client.post( + reverse("documents:folder_delete", args=[self.folder.pk]), + {}, + ) + self.assertRedirects(response, reverse("documents:list")) + self.assertFalse(DocumentFolder.objects.filter(pk=self.folder.pk).exists()) + + def test_non_owner_cannot_delete_folder(self): + self.client.force_login(self.other) + response = self.client.post( + reverse("documents:folder_delete", args=[self.folder.pk]), + {}, + ) + self.assertEqual(response.status_code, 403) + + def test_admin_can_delete_folder(self): + self.client.force_login(self.admin) + response = self.client.post( + reverse("documents:folder_delete", args=[self.folder.pk]), + {}, + ) + self.assertRedirects(response, reverse("documents:list")) + self.assertFalse(DocumentFolder.objects.filter(pk=self.folder.pk).exists()) + + def test_predefined_folder_cannot_be_deleted(self): + self.folder.is_predefined = True + self.folder.save() + + self.client.force_login(self.owner) + response = self.client.post( + reverse("documents:folder_delete", args=[self.folder.pk]), + {}, + ) + self.assertEqual(response.status_code, 403) + + def test_cannot_set_folder_as_child_of_descendant(self): + child = DocumentFolder.objects.create(name="Child", slug="child", created_by=self.owner) + child.parent_folders.add(self.folder) + + self.client.force_login(self.owner) + response = self.client.post( + reverse("documents:folder_edit", args=[self.folder.pk]), + { + "name": self.folder.name, + "description": "", + "application_label": "", + "parent_folders": [child.pk], + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertIn("parent_folders", response.context["form"].errors) + + def test_folder_creation_assigns_owner(self): + self.client.force_login(self.other) + response = self.client.post( + reverse("documents:folder_create"), + { + "name": "New Folder", + "description": "", + "application_label": "", + "parent_folders": [], + }, + ) + created_folder = DocumentFolder.objects.get(name="New Folder") + expected_redirect = f"{reverse('documents:list')}?folder={created_folder.slug}" + self.assertRedirects(response, expected_redirect) + self.assertEqual(created_folder.created_by, self.other) + + +class DocumentDeletionPermissionTests(TestCase): + def setUp(self): + self.owner = User.objects.create_user(username="doc-owner", password="owner") + UserConfig.objects.create(user=self.owner, is_intern=True) + self.manager = User.objects.create_user(username="doc-manager", password="manager") + manager_role, _ = Role.objects.get_or_create(name="manager") + self.manager_config = UserConfig.objects.create(user=self.manager, is_intern=True) + self.manager_config.roles.add(manager_role) + + self.project = Project.objects.create(name="Project linked doc", created_by=self.owner) + self.intervention = Intervention.objects.create(title="Intervention linked doc") + self.folder = DocumentFolder.objects.create( + name="Root", + slug="root", + created_by=self.owner, + ) + + + def test_internal_manager_can_delete_thematic_asset_document_owned_by_others(self): + from common.models import Thematic, UserThematics + from assets.models import TrafficLightIntersection + from documents.models import DocumentFolderAttachment + from django.core.exceptions import PermissionDenied + + 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-TEST-1", name_fr="Carrefour Test 1") + root_folder = DocumentFolder.objects.create(name="Root Intersection Folder", created_by=self.owner) + sub_folder = DocumentFolder.objects.create(name="Subfolder Schemas", created_by=self.owner) + sub_folder.parent_folders.add(root_folder) + + 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 in Subfolder", created_by=self.owner) + other_doc.folders.add(sub_folder) + + # The manager with edit rights on trafficlights should be allowed to delete the document in the subfolder + ensure_document_deletable(other_doc, self.manager) + + def test_internal_manager_without_edit_rights_cannot_delete_thematic_asset_document_owned_by_others(self): + from common.models import Thematic, UserThematics + from assets.models import TrafficLightIntersection + from documents.models import DocumentFolderAttachment + from django.core.exceptions import PermissionDenied + + thematic, _ = Thematic.objects.get_or_create( + code="trafficlights", + defaults={"name_fr": "Signalisation Lumineuse Tricolore", "name_nl": "Verkeerslichten"} + ) + # Manager has can_edit_assets=False + UserThematics.objects.create(user_config=self.manager_config, thematic=thematic, can_edit_assets=False) + + intersection = TrafficLightIntersection.objects.create(code="TL-TEST-2", name_fr="Carrefour Test 2") + root_folder = DocumentFolder.objects.create(name="Root Intersection Folder 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 2", created_by=self.owner) + other_doc.folders.add(root_folder) + + # Deletion should raise PermissionDenied because manager lacks edit rights on the thematic + with self.assertRaises(PermissionDenied): + 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): + def setUp(self): + self.user = User.objects.create_user(username="testuser", password="testpassword") + UserConfig.objects.create(user=self.user, is_intern=True) + self.document = ManagedDocument.objects.create(title="Test Document", created_by=self.user) + self.project = Project.objects.create(name="Awesome project name", created_by=self.user) + self.content_type = ContentType.objects.get_for_model(Project) + + def test_search_objects_anonymous(self): + url = reverse("documents:search_objects", args=[self.document.pk]) + response = self.client.get(url, {"content_type": self.content_type.pk, "q": "Awesome"}) + self.assertEqual(response.status_code, 302) + + def test_search_objects_authenticated(self): + self.client.force_login(self.user) + url = reverse("documents:search_objects", args=[self.document.pk]) + response = self.client.get(url, {"content_type": self.content_type.pk, "q": "Awesome"}) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(len(data["results"]), 1) + self.assertEqual(data["results"][0]["id"], self.project.pk) + self.assertIn("Awesome project name", data["results"][0]["text"]) + + +class DocumentGeoreferenceTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="geo_user", password="password") + UserConfig.objects.create(user=self.user, is_intern=True) + self.document = ManagedDocument.objects.create(title="Geo Doc", created_by=self.user) + + def test_georeference_view_denied_for_anonymous(self): + url = reverse("documents:georeference", args=[self.document.pk]) + response = self.client.get(url) + self.assertEqual(response.status_code, 302) # Redirect to login + + def test_georeference_view_allowed_for_owner(self): + self.client.force_login(self.user) + url = reverse("documents:georeference", args=[self.document.pk]) + response = self.client.get(url, {"mode": "overlay"}) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Superposition") + + def test_georeference_save_success(self): + self.client.force_login(self.user) + url = reverse("documents:georeference_save", args=[self.document.pk]) + coords = [[4.1, 50.1], [4.2, 50.1], [4.2, 50.2], [4.1, 50.2]] + + response = self.client.post( + url, + {"coordinates": coords, "page": 1}, + content_type="application/json" + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["status"], "success") + + # Verify model updated + self.document.refresh_from_db() + self.assertEqual(self.document.georeference_coords, coords) + self.assertEqual(self.document.georeference_page, 1) + + def test_georeference_save_invalid_format(self): + self.client.force_login(self.user) + url = reverse("documents:georeference_save", args=[self.document.pk]) + + # 3 points instead of 4 + coords = [[4.1, 50.1], [4.2, 50.1], [4.2, 50.2]] + response = self.client.post( + url, + {"coordinates": coords}, + content_type="application/json" + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["status"], "error") + + def test_map_view_not_georeferenced(self): + self.client.force_login(self.user) + url = reverse("documents:map_view", args=[self.document.pk]) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_map_view_georeferenced(self): + self.client.force_login(self.user) + self.document.georeference_coords = [[4.1, 50.1], [4.2, 50.1], [4.2, 50.2], [4.1, 50.2]] + self.document.save() + + url = reverse("documents:map_view", args=[self.document.pk]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + +from unittest.mock import patch +from django.core.files.uploadedfile import SimpleUploadedFile +from documents.models import DocumentVersion +import os + +class DocumentCadPreviewTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="cad_user", password="password") + UserConfig.objects.create(user=self.user, is_intern=True) + self.document = ManagedDocument.objects.create(title="Cad Doc", created_by=self.user) + # Create a version with a mock DXF file + self.version = DocumentVersion.objects.create( + document=self.document, + file=SimpleUploadedFile("plan.dxf", b"DUMMY DXF CONTENT"), + uploaded_by=self.user, + version_number=1 + ) + + @patch("documents.utils.render_cad_file_to_image") + def test_dxf_preview_rendering_and_serving(self, mock_render): + # We simulate the render_cad_file_to_image writing a mock file + def fake_render(input_path, output_path): + with open(output_path, "wb") as f: + f.write(b"MOCK PNG CONTENT") + mock_render.side_effect = fake_render + + self.client.force_login(self.user) + url = reverse("documents:page_image", args=[self.document.pk, 1]) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "image/png") + self.assertEqual(b"".join(response.streaming_content), b"MOCK PNG CONTENT") + + # Verify preview file was created on disk + preview_path = self.version.file.path + ".preview.png" + self.assertTrue(os.path.exists(preview_path)) + + # Clean up preview file after test + if os.path.exists(preview_path): + os.remove(preview_path) + + @patch("documents.utils.render_cad_file_to_image") + def test_dwg_preview_rendering_and_serving(self, mock_render): + # Create a document and version with .dwg file + doc_dwg = ManagedDocument.objects.create(title="Dwg Doc", created_by=self.user) + version_dwg = DocumentVersion.objects.create( + document=doc_dwg, + file=SimpleUploadedFile("plan.dwg", b"DUMMY DWG CONTENT"), + uploaded_by=self.user, + version_number=1 + ) + + def fake_render(input_path, output_path): + with open(output_path, "wb") as f: + f.write(b"MOCK DWG PNG CONTENT") + mock_render.side_effect = fake_render + + self.client.force_login(self.user) + url = reverse("documents:page_image", args=[doc_dwg.pk, 1]) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "image/png") + self.assertEqual(b"".join(response.streaming_content), b"MOCK DWG PNG CONTENT") + + # Clean up preview file after test + preview_path = version_dwg.file.path + ".preview.png" + if os.path.exists(preview_path): + os.remove(preview_path) + + +from assets.models import NatureLocation +from documents.models import DocumentFolderAttachment + +class DocumentGeoreferenceAutoZoomTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="geo_user", password="password") + UserConfig.objects.create(user=self.user, is_intern=True) + self.document = ManagedDocument.objects.create( + title="Auto Zoom Doc", + created_by=self.user + ) + self.location = NatureLocation.objects.create( + code="LOC1", + name_fr="Location 1", + lon=4.3517, + lat=50.8503 + ) + + def test_direct_attachment_coords(self): + # Create direct attachment to the geolocated asset + DocumentAttachment.objects.create( + document=self.document, + content_type=ContentType.objects.get_for_model(self.location), + object_id=self.location.pk + ) + + # Test helper function + from .views import _get_associated_asset_coords + coords = _get_associated_asset_coords(self.document) + self.assertEqual(coords, [4.3517, 50.8503]) + + # Test that view injects associated_coords into template context + self.client.force_login(self.user) + url = reverse("documents:georeference", args=[self.document.pk]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context["associated_coords"], [4.3517, 50.8503]) + + def test_folder_attachment_coords(self): + # Create folder and associate with document + folder = DocumentFolder.objects.create(name="Geo Folder", slug="geo-folder") + self.document.folders.add(folder) + + # Create folder attachment to the geolocated asset + DocumentFolderAttachment.objects.create( + folder=folder, + content_type=ContentType.objects.get_for_model(self.location), + object_id=self.location.pk + ) + + from .views import _get_associated_asset_coords + coords = _get_associated_asset_coords(self.document) + self.assertEqual(coords, [4.3517, 50.8503]) + + def test_parent_folder_attachment_coords(self): + # Create parent folder, child folder, and associate with document + parent_folder = DocumentFolder.objects.create(name="Parent Geo Folder", slug="parent-geo-folder") + child_folder = DocumentFolder.objects.create(name="Child Folder", slug="child-folder") + child_folder.parent_folders.add(parent_folder) + self.document.folders.add(child_folder) + + # Create folder attachment to the parent folder + DocumentFolderAttachment.objects.create( + folder=parent_folder, + content_type=ContentType.objects.get_for_model(self.location), + object_id=self.location.pk + ) + + from .views import _get_associated_asset_coords + coords = _get_associated_asset_coords(self.document) + self.assertEqual(coords, [4.3517, 50.8503]) + + +class PublicAccessLinkTests(TestCase): + def setUp(self): + self.owner = User.objects.create_user(username="link_owner", password="pwd") + self.document = ManagedDocument.objects.create(title="Public Test Doc", created_by=self.owner) + self.doc_link = DocumentAccessLink.objects.create( + document=self.document, + permission=ManagedDocument.PERMISSION_VIEW, + ) + self.folder = DocumentFolder.objects.create(name="Public Folder", slug="public-folder", created_by=self.owner) + self.folder_link = DocumentFolderAccessLink.objects.create( + folder=self.folder, + permission=ManagedDocument.PERMISSION_VIEW, + ) + + def test_anonymous_user_can_access_public_document_view(self): + url = reverse("documents:public_view", args=[self.doc_link.token]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Public Test Doc") + + def test_anonymous_user_can_access_public_folder_view(self): + url = reverse("documents:folder_public", args=[self.folder_link.token]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Public Folder") \ No newline at end of file diff --git a/loko/documents/urls.py b/loko/documents/urls.py index ad66bd2..0698c65 100644 --- a/loko/documents/urls.py +++ b/loko/documents/urls.py @@ -1,3 +1,4 @@ +from django.contrib.auth.decorators import login_not_required from django.urls import path from . import views @@ -60,11 +61,11 @@ urlpatterns = [ path("/remove-folder//", views.RemoveFolderFromDocumentView.as_view(), name="remove_folder"), path("/add-tag/", views.AddTagToDocumentView.as_view(), name="add_tag"), path("/remove-tag//", views.RemoveTagFromDocumentView.as_view(), name="remove_tag"), - path("public//", views.PublicDocumentView.as_view(), name="public_view"), - path("public//download/", views.PublicDocumentDownloadView.as_view(), name="public_download"), + path("public//", login_not_required(views.PublicDocumentView.as_view()), name="public_view"), + path("public//download/", login_not_required(views.PublicDocumentDownloadView.as_view()), name="public_download"), path( "folders/public//", - views.PublicFolderView.as_view(), + login_not_required(views.PublicFolderView.as_view()), name="folder_public", ), path("/georeference/", views.DocumentGeoreferenceView.as_view(), name="georeference"), diff --git a/loko/documents/views.py b/loko/documents/views.py index 8ba7e6d..9b1ef62 100644 --- a/loko/documents/views.py +++ b/loko/documents/views.py @@ -3,6 +3,7 @@ import mimetypes import os from django.contrib import messages +from django.contrib.auth.decorators import login_not_required from django.contrib.auth.mixins import LoginRequiredMixin from django.core.exceptions import PermissionDenied from django.db import transaction @@ -11,6 +12,7 @@ from django.db.models.functions import Lower from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse, reverse_lazy from django.utils import timezone +from django.utils.decorators import method_decorator from django.utils.encoding import smart_str from django.utils.http import url_has_allowed_host_and_scheme from django.utils.translation import gettext_lazy as _ @@ -1352,6 +1354,7 @@ class RemoveTagFromDocumentView(LoginRequiredMixin, View): return redirect(document.get_absolute_url()) +@method_decorator(login_not_required, name="dispatch") class PublicDocumentView(View): template_name = "documents/document_public.html" @@ -1376,6 +1379,7 @@ class PublicDocumentView(View): ) +@method_decorator(login_not_required, name="dispatch") class PublicDocumentDownloadView(View): def get(self, request, token): link = get_object_or_404( @@ -1409,6 +1413,7 @@ class PublicDocumentDownloadView(View): return response +@method_decorator(login_not_required, name="dispatch") class PublicFolderView(View): template_name = "documents/documentfolder_public.html"