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