diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6101a2b02..3f1cea350 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,6 +8,10 @@ on: branches: - "19.0" - "19.0-ocabot-*" + # ledoent fork only: allow manual re-trigger when GitHub silently throttles + # fork-PR workflow runs after a burst of pushes. Strip before opening an + # upstream OCA PR (oca-addons-repo-template owns this file). + workflow_dispatch: jobs: unreleased-deps: diff --git a/.gitignore b/.gitignore index 6ec07a054..afb87b435 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,10 @@ docs/_build/ # OCA rules !static/lib/ + +# Session-local planning + audit artefacts (not for upstream) +*-PLAN.md +SESSION-HANDOFF-*.md +DMS-UX-AUDIT-*.md +dms-ux-prototype.html +screenshots/ diff --git a/dms/__manifest__.py b/dms/__manifest__.py index 05b9dd024..a3ea96395 100644 --- a/dms/__manifest__.py +++ b/dms/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Document Management System", "summary": """Document Management System for Odoo""", - "version": "19.0.1.0.0", + "version": "19.0.1.4.0", "category": "Document Management", "license": "LGPL-3", "website": "https://github.com/OCA/dms", @@ -47,18 +47,34 @@ "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/utils/*.esm.js", + "dms/static/src/js/components/*.esm.js", + "dms/static/src/js/components/preview/*.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/components/preview/*.xml", "dms/static/src/js/views/*.xml", + # SCSS + "dms/static/src/scss/dms_ext_palette.scss", + "dms/static/src/scss/file_kanban.scss", + "dms/static/src/scss/dms_directory.scss", + "dms/static/src/scss/dms_form_hero.scss", + "dms/static/src/scss/file_preview_pane.scss", + "dms/static/src/scss/dms_search_facets.scss", ], "web.assets_frontend": [ + "dms/static/src/scss/dms_ext_palette.scss", "dms/static/src/scss/portal.scss", ], "web.assets_tests": [ "dms/static/tests/tours/**/*", ], + "web.assets_unit_tests": [ + "dms/static/tests/**/*.test.js", + ], }, "demo": [ "demo/res_users.xml", 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/models/dms_file.py b/dms/models/dms_file.py index ea4c3df32..dda79255f 100644 --- a/dms/models/dms_file.py +++ b/dms/models/dms_file.py @@ -425,12 +425,8 @@ def _compute_path(self): }, ) current_dir = current_dir.parent_id - record.update( - { - "path_names": "/".join(path_names) if all(path_names) else "", - "path_json": json.dumps(path_json), - } - ) + record.path_names = "/".join(path_names) if all(path_names) else "" + record.path_json = json.dumps(path_json) @api.depends("name", "mimetype", "content") def _compute_extension(self): 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..a27ca8420 --- /dev/null +++ b/dms/static/src/js/components/dms_stat_bar.esm.js @@ -0,0 +1,137 @@ +// 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", + }, + { + 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", + }, +]; + +export class DmsStatBar extends Component { + static template = "dms.StatBar"; + static props = { + stats: {type: [Object, {value: null}], optional: true}, + tiles: {type: Array, optional: true}, + }; + static defaultProps = { + tiles: DEFAULT_TILES, + }; + + get isLoading() { + return !this.props.stats; + } + + 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..273534b01 --- /dev/null +++ b/dms/static/src/js/components/dms_stat_bar.xml @@ -0,0 +1,69 @@ + + + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+
+
diff --git a/dms/static/src/js/components/preview/file_preview_pane.esm.js b/dms/static/src/js/components/preview/file_preview_pane.esm.js new file mode 100644 index 000000000..873830b5e --- /dev/null +++ b/dms/static/src/js/components/preview/file_preview_pane.esm.js @@ -0,0 +1,192 @@ +// Copyright 2026 ledoent — Don Kendall +// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import {Component, useEffect, useState} from "@odoo/owl"; +import {getPreviewActions} from "./preview_action_registry.esm"; +import {getPreviewHandler} from "./preview_registry.esm"; +import {useService} from "@web/core/utils/hooks"; + +// Filename extension → mimetype fallback for `_effectiveMimetype`. libmagic +// returns `application/octet-stream` for several common file types whose +// magic bytes vary across encoders (notably MP4 container variants) — the +// pane would then route to DownloadFallbackPreview even though VideoPreview +// or similar would handle the file fine. Mapping by extension fixes this +// without forcing every uploader to set mimetype manually. +const _EXTENSION_MIMETYPES = { + mp4: "video/mp4", + webm: "video/webm", + mkv: "video/x-matroska", + mov: "video/quicktime", + mp3: "audio/mpeg", + ogg: "audio/ogg", + wav: "audio/wav", + m4a: "audio/mp4", + flac: "audio/flac", + pdf: "application/pdf", + md: "text/markdown", + markdown: "text/markdown", + txt: "text/plain", + json: "application/json", + xml: "application/xml", + js: "application/javascript", + rtf: "text/rtf", + csv: "text/csv", + html: "text/html", + htm: "text/html", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", +}; + +// Mimetypes generic enough that an extension-derived mapping should win. +// libmagic returns `text/plain` for .md/.markdown/.json/.xml/.csv (no magic +// signature distinguishes them from prose), so the registry would route +// those to TextPreview instead of MarkdownPreview / JSON / etc. +const _STORED_OVERRIDABLE = new Set([ + "application/octet-stream", + "application/x-binary", + "text/plain", +]); + +function _effectiveMimetype(file) { + const stored = file.mimetype || ""; + const ext = (file.name || "").split(".").pop().toLowerCase(); + if (stored && !_STORED_OVERRIDABLE.has(stored)) { + return stored; + } + return _EXTENSION_MIMETYPES[ext] || stored; +} + +// Renders the currently-selected dms.file on the right of a split layout. +// Empty state when `recordId` is falsy; loads file metadata via ORM and +// dispatches to the registered handler for that mimetype. +export class FilePreviewPane extends Component { + static template = "dms.FilePreviewPane"; + static props = { + recordId: {type: [Number, {value: null}], optional: true}, + onClose: {type: Function, optional: true}, + }; + + setup() { + this.orm = useService("orm"); + this.action = useService("action"); + this.notification = useService("notification"); + this.state = useState({ + loading: false, + file: null, + error: null, + }); + // Single source of truth for "when to (re)fetch": the recordId prop. + // `useEffect` fires on initial mount AND every time the dependency + // changes, replacing the prior setup()-conditional + onWillUpdateProps + // pair with one declarative wiring. Returns undefined (no teardown) + // because the fetch result lives on this.state which the component + // re-render handles. + useEffect( + (recordId) => { + if (recordId) { + this._load(recordId); + } else { + this.state.file = null; + this.state.error = null; + } + }, + () => [this.props.recordId] + ); + } + + async _load(recordId) { + this.state.loading = true; + this.state.error = null; + try { + const [file] = await this.orm.read( + "dms.file", + [recordId], + ["id", "name", "mimetype", "write_date", "human_size"] + ); + this.state.file = file || null; + } catch (err) { + this.state.error = err.data?.message || err.message || String(err); + this.state.file = null; + } finally { + this.state.loading = false; + } + } + + get handler() { + if (!this.state.file) { + return null; + } + return getPreviewHandler(_effectiveMimetype(this.state.file)); + } + + get HandlerComponent() { + return this.handler?.component || null; + } + + get extraActions() { + return getPreviewActions(this.state.file); + } + + get _services() { + return {action: this.action, orm: this.orm, notification: this.notification}; + } + + onExtraActionClick(actionEntry) { + actionEntry.onClick(this.state.file, this._services); + } + + onCloseClick() { + if (this.props.onClose) { + this.props.onClose(); + } + } + + async onOpenFormClick() { + if (!this.state.file) { + return; + } + // Resolve the addon's registered action so we land in the same + // context the user clicked into (breadcrumbs, search context, etc). + // Plain `{type: "ir.actions.act_window", res_model, res_id}` was + // losing the action context and redirecting to the apps menu. + await this.action.doAction("dms.action_dms_file", { + viewType: "form", + additionalContext: {}, + props: {resId: this.state.file.id}, + }); + } + + onDownloadClick() { + if (!this.state.file) { + return; + } + // Direct content endpoint — `download=true` sends the right Content- + // Disposition header; the browser handles save-as without leaving + // the pane. New tab keeps the kanban/list selection intact. + const url = + `/web/content?model=dms.file&id=${this.state.file.id}` + + `&field=content&filename_field=name&download=true`; + window.open(url, "_blank", "noopener"); + } + + async onShareClick() { + if (!this.state.file) { + return; + } + // The existing dms `wizard_dms_file_share_action` is a binding-model + // action — its underlying `wizard.dms.share` (inherits portal.share) + // reads `active_model` + `active_ids` from context to seed the + // wizard's res_model + res_id fields. + await this.action.doAction("dms.wizard_dms_file_share_action", { + additionalContext: { + active_id: this.state.file.id, + active_ids: [this.state.file.id], + active_model: "dms.file", + }, + }); + } +} diff --git a/dms/static/src/js/components/preview/file_preview_pane.xml b/dms/static/src/js/components/preview/file_preview_pane.xml new file mode 100644 index 000000000..1162c2181 --- /dev/null +++ b/dms/static/src/js/components/preview/file_preview_pane.xml @@ -0,0 +1,122 @@ + + + + + + + diff --git a/dms/static/src/js/components/preview/handlers.esm.js b/dms/static/src/js/components/preview/handlers.esm.js new file mode 100644 index 000000000..f1f359838 --- /dev/null +++ b/dms/static/src/js/components/preview/handlers.esm.js @@ -0,0 +1,271 @@ +// Copyright 2026 ledoent — Don Kendall +// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import {Component, onWillStart, useState} from "@odoo/owl"; +import {previewRegistry} from "./preview_registry.esm"; + +// --------------------------------------------------------------------------- +// Built-in preview handlers +// +// Each handler is a tiny OWL component receiving a `file` prop: +// {id, name, mimetype, write_date} +// Handlers render a fixed-height container (CSS handles sizing) and either +// embed the file or expose a click-out affordance. +// --------------------------------------------------------------------------- + +const fileProps = {file: {type: Object}}; +const downloadUrl = (file) => + `/web/content?id=${file.id}&model=dms.file&field=content` + + `&filename_field=name&download=true`; + +// Image preview: native at /web/image//image_1920. +export class ImagePreview extends Component { + static template = "dms.preview.Image"; + static props = fileProps; + + get src() { + return `/web/image/dms.file/${this.props.file.id}/image_1920`; + } +} + +// PDF: native