Commit 2675d0a5 authored by Mickaël Desfrênes's avatar Mickaël Desfrênes
Browse files

Add collection and resource paths to Excel exports

Build paths from the batched collection tree and reuse them for each resource membership. Verify that imports ignore edited paths and export query counts stay constant as the subtree grows.

Bump Jama to 2026.10.14.0.
parent da645581
Loading
Loading
Loading
Loading
+1 −1
Changes for src/jama/__init__.py: 1 added line, 1 removed line.
Original line number Diff line number Diff line
__version__ = "2026.10.13.0"
__version__ = "2026.10.14.0"
+21 −1
Changes for src/resources/models.py: 21 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -32,6 +32,7 @@ XLSX_EXPORT_BASE_HEADERS = (
    "cascade",
    "title",
    "ark (lecture seule)",
    "path (lecture seule)",
)
XLSX_EXPORT_EXCLUDED_METADATA_TITLES = ("ExifTool", "OCR", "scd_cms")

@@ -54,6 +55,8 @@ def _flatten_resource(
    metadata_values: List["MetadataResourceValue"],
    metadata_column_indexes: Dict[int, int],
    metadata_columns_count: int,
    *,
    path: str,
) -> List[Union[int, str, None]]:
    row = [
        resource.pk,
@@ -61,6 +64,7 @@ def _flatten_resource(
        "n",
        resource.title,
        resource.ark if resource.ark else "",
        path,
        *([None] * metadata_columns_count),
    ]
    for mr in metadata_values:
@@ -972,6 +976,8 @@ class Collection(models.Model):
        metadata_values: List["MetadataCollectionValue"],
        metadata_column_indexes: Dict[int, int],
        metadata_columns_count: int,
        *,
        path: str,
    ) -> List[Union[int, str, None]]:
        row = [
            None,
@@ -979,6 +985,7 @@ class Collection(models.Model):
            "n",
            self.title,
            self.ark if self.ark else "",
            path,
            *([None] * metadata_columns_count),
        ]
        for mr in metadata_values:
@@ -1144,12 +1151,23 @@ class Collection(models.Model):
        }
        collection_rows = self._ordered_subtree_collections_for_export()
        collection_ids = [collection.pk for collection in collection_rows]
        ancestors = self.ancestors() if collection_rows else []
        root_parent_path = "/" + "".join(f"{ancestor.title}/" for ancestor in ancestors)
        collection_paths: dict[int, str] = {}
        # Parent-first rows let every membership reuse its collection's full path.
        for collection in collection_rows:
            parent_path = (
                root_parent_path
                if collection.pk == self.pk
                else collection_paths[collection.parent_id]
            )
            collection_paths[collection.pk] = f"{parent_path}{collection.title}/"
        readable_collection_rows = collection_rows
        if acl is not None:
            # Reuse the subtree and the export root's ancestors for every collection check.
            collections_by_id = {
                collection.pk: collection
                for collection in [*collection_rows, *self.ancestors()]
                for collection in [*collection_rows, *ancestors]
            }
            for collection in collection_rows:
                collection.parent = collections_by_id.get(collection.parent_id)
@@ -1235,6 +1253,7 @@ class Collection(models.Model):
                collection_values_by_collection_id[collection.pk],
                metadata_column_indexes,
                metadata_columns_count,
                path=collection_paths[collection.pk],
            )
        for collection in collection_rows:
            for membership in memberships_by_collection_id[collection.pk]:
@@ -1245,6 +1264,7 @@ class Collection(models.Model):
                    resource_values_by_resource_id[membership.resource_id],
                    metadata_column_indexes,
                    metadata_columns_count,
                    path=collection_paths[collection.pk] + membership.resource.title,
                )

    def export_to_xlsx(
+89 −0
Changes for src/rpc/tests.py: 89 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -956,6 +956,11 @@ class XlsxExportAccessTestCase(TestCase):
            {row[0] for row in rows[1:] if row[0]},
            {child_resource.pk, allowed_resource.pk},
        )
        path_index = rows[0].index("path (lecture seule)")
        self.assertEqual(
            [row[path_index] for row in rows[1:] if row[0] == allowed_resource.pk],
            ["/Root/Ancestor/Exported/Denied collection/Explicitly allowed resource"],
        )

    def test_inherited_grants_from_ancestors_outside_export_subtree(self):
        set_permission_on_role(self.role, "resource", can_read=False)
@@ -1018,6 +1023,14 @@ class XlsxExportAccessTestCase(TestCase):
            [row[0] for row in resource_rows],
            [first.pk, second.pk, shared.pk, shared.pk],
        )
        path_index = rows[0].index("path (lecture seule)")
        self.assertEqual(
            [row[path_index] for row in resource_rows[-2:]],
            [
                "/Root/Ancestor/Exported/A child/Shared",
                "/Root/Ancestor/Exported/B child/Shared",
            ],
        )
        metadata_index = rows[0].index(str(self.metadata))
        self.assertEqual(
            resource_rows[-1][metadata_index],
@@ -1026,6 +1039,82 @@ class XlsxExportAccessTestCase(TestCase):
            ),
        )

    def test_paths_include_ancestors_and_use_collection_and_resource_titles(self):
        child = self._collection("Sub collection", self.collection)
        grandchild = self._collection("Another sub collection", child)
        resource = self._resource("resource.jpg", grandchild, file_hash="b" * 64)

        rows = self._download_rows()

        path_index = rows[0].index("path (lecture seule)")
        self.assertEqual(
            {row[1]: row[path_index] for row in rows[1:] if row[1]},
            {
                self.collection.pk: "/Root/Ancestor/Exported/",
                child.pk: "/Root/Ancestor/Exported/Sub collection/",
                grandchild.pk: (
                    "/Root/Ancestor/Exported/Sub collection/Another sub collection/"
                ),
            },
        )
        self.assertEqual(
            [row[path_index] for row in rows[1:] if row[0] == resource.pk],
            [
                "/Root/Ancestor/Exported/Sub collection/Another sub collection/resource.jpg"
            ],
        )
        self.assertTrue(all(len(row) == len(rows[0]) for row in rows))

    def test_trusted_export_paths_from_project_root_without_metadata(self):
        resource = self._resource("resource.jpg", self.collection)
        self.metadata_set.delete()

        rows = list(self.root.yield_resource_data_for_export())

        self.assertEqual(rows[0], list(models.XLSX_EXPORT_BASE_HEADERS))
        path_index = rows[0].index("path (lecture seule)")
        self.assertEqual(rows[1][path_index], "/Root/")
        self.assertEqual(
            [row[path_index] for row in rows[1:] if row[0] == resource.pk],
            ["/Root/Ancestor/Exported/resource.jpg"],
        )
        self.assertTrue(all(len(row) == len(rows[0]) for row in rows))

    def test_import_ignores_edited_paths_for_collections_and_resources(self):
        set_permission_on_role(self.role, "collection", can_read=True, can_update=True)
        set_permission_on_role(self.role, "resource", can_read=True, can_update=True)
        resource = self._resource("resource.jpg", self.collection)
        rows = self._download_rows()
        collection_data = dict(zip(rows[0], rows[1]))
        resource_data = dict(zip(rows[0], rows[2]))
        for data in (collection_data, resource_data):
            data["path (lecture seule)"] = "/Other collection/ignored name"
            data["title"] = "Updated title"
            data[str(self.metadata)] = "Updated metadata"

        methods.update_collection_from_xlsx_row(self.user, collection_data)
        methods.update_resource_from_xlsx_row(self.user, resource_data)

        self.collection.refresh_from_db()
        resource.refresh_from_db()
        self.assertEqual(self.collection.parent_id, self.ancestor.pk)
        self.assertEqual(
            list(resource.collection_set.values_list("pk", flat=True)),
            [self.collection.pk],
        )
        self.assertEqual(self.collection.title, "Updated title")
        self.assertEqual(resource.title, "Updated title")
        self.assertEqual(
            self.collection.metadatacollectionvalue_set.get(
                metadata=self.metadata
            ).value,
            "Updated metadata",
        )
        self.assertEqual(
            resource.metadataresourcevalue_set.get(metadata=self.metadata).value,
            "Updated metadata",
        )

    def test_denied_export_root_returns_forbidden(self):
        set_permission_on_role(self.role, self.collection, can_read=False)