diff --git a/dms/__manifest__.py b/dms/__manifest__.py
index 05b9dd024..451835237 100644
--- a/dms/__manifest__.py
+++ b/dms/__manifest__.py
@@ -47,11 +47,15 @@
"dms/static/src/models/*.js",
"dms/static/src/js/fields/path_json/path_owl.esm.js",
"dms/static/src/js/fields/preview_binary/preview_record.esm.js",
+ "dms/static/src/js/components/*.esm.js",
"dms/static/src/js/views/*.esm.js",
# XML
"dms/static/src/js/fields/path_json/path_owl.xml",
"dms/static/src/js/fields/preview_binary/preview_record.xml",
+ "dms/static/src/js/components/*.xml",
"dms/static/src/js/views/*.xml",
+ # SCSS
+ "dms/static/src/scss/dms_directory.scss",
],
"web.assets_frontend": [
"dms/static/src/scss/portal.scss",
diff --git a/dms/models/directory.py b/dms/models/directory.py
index 0eb42f15a..b1d54d9aa 100644
--- a/dms/models/directory.py
+++ b/dms/models/directory.py
@@ -10,6 +10,7 @@
import os
from ast import literal_eval
from collections import defaultdict
+from datetime import timedelta
from typing import Literal # noqa # pylint: disable=unused-import
from odoo import api, fields, models, tools
@@ -786,3 +787,86 @@ def action_dms_files_all_directory(self):
searchpanel_default_directory_id=self.id,
)
return action
+
+ @api.model
+ def get_dashboard_stats(self):
+ # Global file stats scoped by the current user's ir.rule access.
+ # Stats are global across all readable files; directory-domain
+ # translation is deliberately not applied in this iteration.
+ #
+ # Sparklines + deltas are computed live via _read_group over
+ # create_date (always indexed by Odoo) — no snapshot table required.
+ # The arrays describe *activity* (creations), not state-over-time;
+ # storage_sparkline shows daily bytes-added, not the running total
+ # (which would require a snapshot to be faithful under deletions).
+ File = self.env["dms.file"]
+ now = fields.Datetime.now()
+ files_total = File.search_count([])
+ storage_groups = File._read_group(
+ domain=[], groupby=[], aggregates=["size:sum"]
+ )
+ storage_bytes = int(storage_groups[0][0] or 0) if storage_groups else 0
+ new_today = File.search_count([("create_date", ">=", now - timedelta(days=1))])
+
+ # 30-day daily buckets: (created_count, size_sum) per day.
+ day_start = (now - timedelta(days=29)).replace(
+ hour=0, minute=0, second=0, microsecond=0
+ )
+ daily_rows = File._read_group(
+ domain=[("create_date", ">=", day_start)],
+ groupby=["create_date:day"],
+ aggregates=["__count", "size:sum"],
+ )
+ daily_by_key = {}
+ for day_value, count, size_sum in daily_rows:
+ if not day_value:
+ continue
+ key = day_value.date().isoformat()
+ daily_by_key[key] = (int(count or 0), int(size_sum or 0))
+ files_sparkline = []
+ storage_sparkline = []
+ for offset in range(29, -1, -1):
+ day = (now - timedelta(days=offset)).date().isoformat()
+ count, size_sum = daily_by_key.get(day, (0, 0))
+ files_sparkline.append(count)
+ storage_sparkline.append(size_sum)
+
+ # 24 hourly buckets across the past day for the "new today" tile.
+ hour_start = (now - timedelta(hours=23)).replace(
+ minute=0, second=0, microsecond=0
+ )
+ hourly_rows = File._read_group(
+ domain=[("create_date", ">=", hour_start)],
+ groupby=["create_date:hour"],
+ aggregates=["__count"],
+ )
+ hourly_by_key = {}
+ for hour_value, count in hourly_rows:
+ if not hour_value:
+ continue
+ hourly_by_key[hour_value.replace(minute=0, second=0, microsecond=0)] = int(
+ count or 0
+ )
+ new_today_sparkline = []
+ for offset in range(23, -1, -1):
+ slot = (now - timedelta(hours=offset)).replace(
+ minute=0, second=0, microsecond=0
+ )
+ new_today_sparkline.append(hourly_by_key.get(slot, 0))
+
+ files_last_week = sum(files_sparkline[-7:])
+ storage_last_week = sum(storage_sparkline[-7:])
+ avg_per_day = round(sum(files_sparkline[-7:]) / 7.0, 1)
+
+ return {
+ "files_total": files_total,
+ "storage_total_bytes": storage_bytes,
+ "storage_total_human": human_size(storage_bytes),
+ "new_today": new_today,
+ "files_sparkline": files_sparkline,
+ "storage_sparkline": storage_sparkline,
+ "new_today_sparkline": new_today_sparkline,
+ "files_delta_week": files_last_week,
+ "storage_delta_week_human": human_size(storage_last_week),
+ "new_today_avg_per_day": avg_per_day,
+ }
diff --git a/dms/static/src/js/components/dms_stat_bar.esm.js b/dms/static/src/js/components/dms_stat_bar.esm.js
new file mode 100644
index 000000000..e670c32a7
--- /dev/null
+++ b/dms/static/src/js/components/dms_stat_bar.esm.js
@@ -0,0 +1,154 @@
+// Copyright 2026 ledoent — Don Kendall
+// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
+
+import {Component} from "@odoo/owl";
+
+// Reusable stat bar. Driven by a `stats` prop shaped like:
+// {files_total, storage_total_human, new_today,
+// files_sparkline, storage_sparkline, new_today_sparkline,
+// files_delta_week, storage_delta_week_human, new_today_avg_per_day}
+// Tile config is declared inline so future dashboards (Phase 3+) can extend
+// or remix the same component by passing a different `tiles` prop. Each tile
+// names the value key, sparkline key, delta key, and a `chart` hint that
+// picks the SVG renderer (line vs bar).
+const SPARK_WIDTH = 80;
+const SPARK_HEIGHT = 26;
+
+const DEFAULT_TILES = [
+ {
+ key: "files_total",
+ label: "Files",
+ icon: "fa-file-text-o",
+ tint: "files",
+ sparklineKey: "files_sparkline",
+ chart: "line",
+ deltaKey: "files_delta_week",
+ deltaSuffix: " this week",
+ deltaTrend: "up",
+ action: "files",
+ },
+ {
+ key: "storage_total_human",
+ label: "Storage",
+ icon: "fa-database",
+ tint: "storage",
+ sparklineKey: "storage_sparkline",
+ chart: "bar",
+ deltaKey: "storage_delta_week_human",
+ deltaSuffix: " added this week",
+ deltaTrend: "neutral",
+ },
+ {
+ key: "new_today",
+ label: "New today",
+ icon: "fa-clock-o",
+ tint: "fresh",
+ sparklineKey: "new_today_sparkline",
+ chart: "line",
+ deltaKey: "new_today_avg_per_day",
+ deltaPrefix: "vs avg ",
+ deltaSuffix: "/day",
+ deltaTrend: "neutral",
+ action: "today",
+ },
+];
+
+export class DmsStatBar extends Component {
+ static template = "dms.StatBar";
+ static props = {
+ stats: {type: [Object, {value: null}], optional: true},
+ tiles: {type: Array, optional: true},
+ // Optional drill-down: called with the clicked tile when it carries
+ // an `action` and a handler is wired by the owning renderer.
+ onTileClick: {type: Function, optional: true},
+ };
+ static defaultProps = {
+ tiles: DEFAULT_TILES,
+ };
+
+ get isLoading() {
+ return !this.props.stats;
+ }
+
+ isClickable(tile) {
+ return (
+ Boolean(tile.action) && Boolean(this.props.onTileClick) && !this.isLoading
+ );
+ }
+
+ onTileClick(tile) {
+ if (this.isClickable(tile)) {
+ this.props.onTileClick(tile);
+ }
+ }
+
+ valueFor(tile) {
+ if (this.isLoading) {
+ return "—";
+ }
+ const raw = this.props.stats[tile.key];
+ return raw === undefined || raw === null ? "—" : raw;
+ }
+
+ // Returns {points, polygon, max, min, hasData} for the tile's series.
+ // Empty / all-zero series → hasData=false so the template can skip the
+ // chart and still keep the tile's vertical rhythm.
+ sparkPath(tile) {
+ if (this.isLoading || !tile.sparklineKey) {
+ return {hasData: false};
+ }
+ const series = this.props.stats[tile.sparklineKey];
+ if (!Array.isArray(series) || series.length === 0) {
+ return {hasData: false};
+ }
+ const max = Math.max(...series, 0);
+ const min = Math.min(...series, 0);
+ const range = max - min || 1;
+ const stepX = series.length > 1 ? SPARK_WIDTH / (series.length - 1) : 0;
+ const points = series.map((v, i) => {
+ const x = Number((i * stepX).toFixed(2));
+ const y = Number(
+ (SPARK_HEIGHT - ((v - min) / range) * SPARK_HEIGHT).toFixed(2)
+ );
+ return {x, y, value: v};
+ });
+ const linePath = points.map((p) => `${p.x},${p.y}`).join(" ");
+ const areaPath = `0,${SPARK_HEIGHT} ${linePath} ${SPARK_WIDTH},${SPARK_HEIGHT}`;
+ const barWidth = series.length ? (SPARK_WIDTH / series.length) * 0.7 : 0;
+ const bars = points.map((p, i) => ({
+ x: Number((i * (SPARK_WIDTH / series.length)).toFixed(2)),
+ y: p.y,
+ width: barWidth,
+ height: Number((SPARK_HEIGHT - p.y).toFixed(2)),
+ }));
+ return {
+ hasData: max > 0,
+ points,
+ linePath,
+ areaPath,
+ bars,
+ last: points[points.length - 1],
+ };
+ }
+
+ deltaText(tile) {
+ if (this.isLoading || !tile.deltaKey) {
+ return "";
+ }
+ const raw = this.props.stats[tile.deltaKey];
+ if (raw === undefined || raw === null) {
+ return "";
+ }
+ const prefix = tile.deltaPrefix || "";
+ const suffix = tile.deltaSuffix || "";
+ return `${prefix}${raw}${suffix}`;
+ }
+
+ sparkWidth() {
+ return SPARK_WIDTH;
+ }
+
+ sparkHeight() {
+ return SPARK_HEIGHT;
+ }
+}
diff --git a/dms/static/src/js/components/dms_stat_bar.xml b/dms/static/src/js/components/dms_stat_bar.xml
new file mode 100644
index 000000000..e673f8c09
--- /dev/null
+++ b/dms/static/src/js/components/dms_stat_bar.xml
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dms/static/src/js/views/dms_directory_kanban_renderer.esm.js b/dms/static/src/js/views/dms_directory_kanban_renderer.esm.js
new file mode 100644
index 000000000..a7888b814
--- /dev/null
+++ b/dms/static/src/js/views/dms_directory_kanban_renderer.esm.js
@@ -0,0 +1,59 @@
+// Copyright 2026 ledoent — Don Kendall
+// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
+
+import {onWillStart, useState} from "@odoo/owl";
+import {DmsStatBar} from "../components/dms_stat_bar.esm";
+import {KanbanRenderer} from "@web/views/kanban/kanban_renderer";
+import {_t} from "@web/core/l10n/translation";
+import {serializeDateTime} from "@web/core/l10n/dates";
+import {useService} from "@web/core/utils/hooks";
+
+const {DateTime} = luxon;
+
+export class DmsDirectoryKanbanRenderer extends KanbanRenderer {
+ static template = "dms.DirectoryKanbanRenderer";
+ static components = {
+ ...KanbanRenderer.components,
+ DmsStatBar,
+ };
+
+ setup() {
+ super.setup();
+ this.orm = useService("orm");
+ this.action = useService("action");
+ this.statsState = useState({stats: null});
+ onWillStart(async () => {
+ this.statsState.stats = await this.orm.call(
+ "dms.directory",
+ "get_dashboard_stats",
+ []
+ );
+ });
+ }
+
+ get stats() {
+ return this.statsState.stats;
+ }
+
+ // Drill-down from a dashboard tile into the matching file list — the
+ // native Odoo-dashboard interaction. "Files" opens all files; "New
+ // today" opens files created since local midnight.
+ onTileClick(tile) {
+ if (tile.action === "files") {
+ this.action.doAction("dms.action_dms_file");
+ } else if (tile.action === "today") {
+ const since = serializeDateTime(DateTime.local().startOf("day"));
+ this.action.doAction({
+ type: "ir.actions.act_window",
+ name: _t("Files added today"),
+ res_model: "dms.file",
+ views: [
+ [false, "kanban"],
+ [false, "list"],
+ ],
+ domain: [["create_date", ">=", since]],
+ target: "current",
+ });
+ }
+ }
+}
diff --git a/dms/static/src/js/views/dms_directory_kanban_renderer.xml b/dms/static/src/js/views/dms_directory_kanban_renderer.xml
new file mode 100644
index 000000000..484e74e7e
--- /dev/null
+++ b/dms/static/src/js/views/dms_directory_kanban_renderer.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
diff --git a/dms/static/src/js/views/dms_directory_kanban_view.esm.js b/dms/static/src/js/views/dms_directory_kanban_view.esm.js
new file mode 100644
index 000000000..8099a8b82
--- /dev/null
+++ b/dms/static/src/js/views/dms_directory_kanban_view.esm.js
@@ -0,0 +1,13 @@
+// Copyright 2026 ledoent — Don Kendall
+// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
+
+import {DmsDirectoryKanbanRenderer} from "./dms_directory_kanban_renderer.esm";
+import {kanbanView} from "@web/views/kanban/kanban_view";
+import {registry} from "@web/core/registry";
+
+export const DmsDirectoryKanbanView = {
+ ...kanbanView,
+ Renderer: DmsDirectoryKanbanRenderer,
+};
+
+registry.category("views").add("dms_directory_kanban", DmsDirectoryKanbanView);
diff --git a/dms/static/src/scss/dms_directory.scss b/dms/static/src/scss/dms_directory.scss
new file mode 100644
index 000000000..5345793cc
--- /dev/null
+++ b/dms/static/src/scss/dms_directory.scss
@@ -0,0 +1,167 @@
+// Copyright 2026 ledoent — Don Kendall
+// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
+
+// Directories landing dashboard stat bar. Sits above the kanban grid as a
+// flow-layout block (not absolutely positioned), so it pushes the grid down
+// rather than overlapping it.
+
+// The renderer injects the stat bar as a sibling of the searchpanel + the
+// kanban renderer inside ``,
+// which is `display: flex; flex-direction: row`. Float the bar absolutely
+// above the row + give the parent top padding so the searchpanel + kanban
+// shift down to make room. This keeps the row layout intact (kanban still
+// fills 1220px next to the 220px searchpanel) while showing the bar full
+// width at the top.
+main.o_content.o_component_with_search_panel:has(.o_dms_stat_bar) {
+ position: relative;
+ padding-top: 88px;
+}
+
+.o_dms_stat_bar {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 5;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+ padding: 10px 16px 14px;
+ background-color: var(--bs-body-bg, #fff);
+ border-bottom: 1px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
+
+ @media (max-width: 575.98px) {
+ grid-template-columns: 1fr;
+ gap: 8px;
+ }
+
+ &[data-loading="true"] {
+ opacity: 0.6;
+ }
+
+ &__tile {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ background-color: var(--bs-body-bg, #fff);
+ border: 1px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
+ border-radius: 6px;
+ min-width: 0;
+ position: relative;
+
+ // Drill-down tiles (Files / New today) read as buttons: pointer +
+ // hover lift toward the primary, matching the preview toggle.
+ &--clickable {
+ cursor: pointer;
+ transition:
+ border-color 140ms ease,
+ box-shadow 140ms ease,
+ transform 140ms ease;
+
+ &:hover {
+ border-color: rgba(113, 75, 103, 0.35);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+ transform: translateY(-1px);
+ }
+ &:focus-visible {
+ outline: 2px solid var(--bs-primary, #714b67);
+ outline-offset: 1px;
+ }
+ }
+ }
+
+ &__spark {
+ width: 80px;
+ height: 26px;
+ flex: 0 0 80px;
+ margin-left: auto;
+ color: var(--tile-accent, var(--bs-primary, #714b67));
+ }
+
+ &__spark_line {
+ stroke: currentColor;
+ stroke-width: 1.4;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ }
+
+ &__spark_area {
+ fill: currentColor;
+ opacity: 0.12;
+ }
+
+ &__spark_dot {
+ fill: currentColor;
+ }
+
+ &__spark_bar {
+ fill: currentColor;
+ opacity: 0.65;
+
+ &:last-of-type {
+ opacity: 1;
+ }
+ }
+
+ &__delta {
+ font-size: 0.7rem;
+ color: var(--bs-secondary-color, #6c757d);
+ margin-top: 2px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ &__icon {
+ flex: 0 0 32px;
+ width: 32px;
+ height: 32px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.1rem;
+ color: var(--tile-accent, var(--bs-primary, #714b67));
+ background-color: var(--tile-bg, var(--bs-tertiary-bg, #f4f5f7));
+ border-radius: 50%;
+ }
+
+ // Per-tile tint — each semantic gets its own colour family so the bar
+ // reads as 3 distinct facets, not 3 copies of the same icon.
+ &__tile[data-tint="files"] {
+ --tile-accent: #1971c2;
+ --tile-bg: rgba(25, 113, 194, 0.1);
+ }
+ &__tile[data-tint="storage"] {
+ --tile-accent: #2f9e44;
+ --tile-bg: rgba(47, 158, 68, 0.1);
+ }
+ &__tile[data-tint="fresh"] {
+ --tile-accent: #e8590c;
+ --tile-bg: rgba(232, 89, 12, 0.1);
+ }
+
+ &__body {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ flex: 1 1 auto;
+ }
+
+ &__value {
+ font-size: 1.125rem;
+ font-weight: 600;
+ line-height: 1.25;
+ color: var(--bs-emphasis-color, #212529);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &__label {
+ font-size: 0.75rem;
+ color: var(--bs-secondary-color, #6c757d);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ }
+}
diff --git a/dms/tests/__init__.py b/dms/tests/__init__.py
index 2502c8f40..8f32c4e44 100644
--- a/dms/tests/__init__.py
+++ b/dms/tests/__init__.py
@@ -5,3 +5,4 @@
from . import test_file
from . import test_benchmark
from . import test_portal
+from . import test_dashboard_stats
diff --git a/dms/tests/test_dashboard_stats.py b/dms/tests/test_dashboard_stats.py
new file mode 100644
index 000000000..c1f1bc03c
--- /dev/null
+++ b/dms/tests/test_dashboard_stats.py
@@ -0,0 +1,130 @@
+# Copyright 2026 ledoent — Don Kendall
+# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
+#
+# Tests the live-aggregation dashboard contract:
+# - dms.directory.get_dashboard_stats() returns 30-day daily sparklines,
+# 24-hour hourly sparkline, and 7-day deltas
+# - dms.file._compute_path() builds a slash-joined path including the file's
+# own filename at the end
+#
+# Both are pure-functional reads — no fixtures beyond a small file tree.
+
+from .common import StorageDatabaseBaseCase
+
+
+class TestDashboardStats(StorageDatabaseBaseCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ # Seed a second file in the same directory so we have >1 file for
+ # the sparkline / size aggregation assertions.
+ cls.file_2 = cls.create_file(directory=cls.directory)
+
+ def test_returns_expected_keys(self):
+ # The shape contract is what the OWL DmsStatBar consumes. Adding a
+ # new key is fine; removing one breaks the rendered tile.
+ stats = self.directory_model.get_dashboard_stats()
+ expected = {
+ "files_total",
+ "storage_total_bytes",
+ "storage_total_human",
+ "new_today",
+ "files_sparkline",
+ "storage_sparkline",
+ "new_today_sparkline",
+ "files_delta_week",
+ "storage_delta_week_human",
+ "new_today_avg_per_day",
+ }
+ self.assertTrue(
+ expected.issubset(stats.keys()),
+ f"missing keys: {expected - set(stats.keys())}",
+ )
+
+ def test_sparkline_lengths_match_window(self):
+ stats = self.directory_model.get_dashboard_stats()
+ # 30-day daily window → 30 buckets each for files + storage.
+ self.assertEqual(len(stats["files_sparkline"]), 30)
+ self.assertEqual(len(stats["storage_sparkline"]), 30)
+ # 24-hour hourly window → 24 buckets.
+ self.assertEqual(len(stats["new_today_sparkline"]), 24)
+
+ def test_files_total_matches_search_count(self):
+ # Stats should agree with a direct search_count — they're both
+ # under the same ir.rule access. If they diverge we have a hidden
+ # filter in the dashboard method.
+ stats = self.directory_model.get_dashboard_stats()
+ direct = self.file_model.search_count([])
+ self.assertEqual(stats["files_total"], direct)
+
+ def test_storage_total_aggregates_size_field(self):
+ stats = self.directory_model.get_dashboard_stats()
+ # storage_total_bytes is computed via _read_group("size:sum"); make
+ # sure it matches the sum of every readable file's `size` field.
+ direct = sum(self.file_model.search([]).mapped("size"))
+ self.assertEqual(stats["storage_total_bytes"], int(direct))
+ # Human-formatted variant is non-empty + has a unit suffix.
+ self.assertTrue(stats["storage_total_human"])
+
+ def test_sparkline_today_bucket_has_newly_created_files(self):
+ # Both seeded files were created in `setUpClass` → today's daily
+ # bucket (index 29, the last in the 30-day window) must be ≥ the
+ # number of files we just created.
+ stats = self.directory_model.get_dashboard_stats()
+ today_count = stats["files_sparkline"][-1]
+ # Allow ≥2 in case other tests in the same suite created more.
+ self.assertGreaterEqual(today_count, 2)
+
+ def test_delta_week_is_sum_of_last_seven_daily_buckets(self):
+ stats = self.directory_model.get_dashboard_stats()
+ expected = sum(stats["files_sparkline"][-7:])
+ self.assertEqual(stats["files_delta_week"], expected)
+
+ def test_avg_per_day_is_seven_day_average(self):
+ stats = self.directory_model.get_dashboard_stats()
+ expected = round(sum(stats["files_sparkline"][-7:]) / 7.0, 1)
+ self.assertEqual(stats["new_today_avg_per_day"], expected)
+
+
+class TestComputePath(StorageDatabaseBaseCase):
+ def test_root_file_path_includes_root_directory_name(self):
+ # `path_names` walks the parent chain back to the storage root —
+ # for a file directly under the root directory the shape is
+ # "/".
+ path = self.file.path_names
+ self.assertIn("/", path)
+ self.assertTrue(path.endswith(self.file.name))
+ self.assertTrue(path.startswith(self.directory.name))
+
+ def test_nested_file_path_includes_full_chain(self):
+ # Build a 3-level chain: root → sub → leaf, file under leaf.
+ sub = self.directory_model.create(
+ {
+ "name": "subdir-test",
+ "parent_id": self.directory.id,
+ "group_ids": [(6, 0, [self.access_group.id])],
+ }
+ )
+ leaf = self.directory_model.create(
+ {
+ "name": "leafdir-test",
+ "parent_id": sub.id,
+ "group_ids": [(6, 0, [self.access_group.id])],
+ }
+ )
+ f = self.create_file(directory=leaf)
+ # path = "///"
+ parts = f.path_names.split("/")
+ self.assertEqual(parts[0], self.directory.name)
+ self.assertEqual(parts[1], "subdir-test")
+ self.assertEqual(parts[2], "leafdir-test")
+ self.assertEqual(parts[3], f.name)
+
+ def test_path_json_round_trips_through_json_load(self):
+ import json
+
+ chain = json.loads(self.file.path_json)
+ # Last entry is the file itself; previous entries are directories.
+ self.assertEqual(chain[-1]["model"], "dms.file")
+ self.assertEqual(chain[-1]["name"], self.file.name)
+ self.assertEqual(chain[0]["model"], "dms.directory")
diff --git a/dms/views/dms_directory.xml b/dms/views/dms_directory.xml
index 4d048a53f..205699d0f 100644
--- a/dms/views/dms_directory.xml
+++ b/dms/views/dms_directory.xml
@@ -192,6 +192,7 @@
dms.directory