diff --git a/spreadsheet_dashboard_share_oca/README.rst b/spreadsheet_dashboard_share_oca/README.rst new file mode 100644 index 00000000..221b42a0 --- /dev/null +++ b/spreadsheet_dashboard_share_oca/README.rst @@ -0,0 +1,9 @@ +============================= +Spreadsheet Dashboard Share +============================= + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + This file is generated by oca-gen-addon-readme from + the readme fragments; do not edit it directly. + +.. automodule:: spreadsheet_dashboard_share_oca diff --git a/spreadsheet_dashboard_share_oca/__init__.py b/spreadsheet_dashboard_share_oca/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/spreadsheet_dashboard_share_oca/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/spreadsheet_dashboard_share_oca/__manifest__.py b/spreadsheet_dashboard_share_oca/__manifest__.py new file mode 100644 index 00000000..495f6758 --- /dev/null +++ b/spreadsheet_dashboard_share_oca/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2026 Volkan Tasci +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Spreadsheet Dashboard Share OCA", + "summary": "Manage dashboard share links", + "version": "19.0.1.0.0", + "license": "AGPL-3", + "author": "Volkan Tasci,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/spreadsheet", + "depends": ["spreadsheet_dashboard"], + "data": ["security/ir_rule.xml"], + "assets": { + "spreadsheet.o_spreadsheet": [ + "spreadsheet_dashboard_share_oca/static/src/bundle/**/*.js", + "spreadsheet_dashboard_share_oca/static/src/bundle/**/*.xml", + ], + "web.assets_unit_tests": [ + "spreadsheet_dashboard_share_oca/static/tests/**/*", + ], + }, + "maintainers": ["volkantasci"], +} diff --git a/spreadsheet_dashboard_share_oca/i18n/spreadsheet_dashboard_share_oca.pot b/spreadsheet_dashboard_share_oca/i18n/spreadsheet_dashboard_share_oca.pot new file mode 100644 index 00000000..ed92fc8a --- /dev/null +++ b/spreadsheet_dashboard_share_oca/i18n/spreadsheet_dashboard_share_oca.pot @@ -0,0 +1,98 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * spreadsheet_dashboard_share_oca +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0-20260723\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-04 14:15+0000\n" +"PO-Revision-Date: 2026-08-04 14:15+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: spreadsheet_dashboard_share_oca +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_share_oca/static/src/bundle/share_manage/share_manage_dialog.xml:0 +msgid "Actions" +msgstr "" + +#. module: spreadsheet_dashboard_share_oca +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_share_oca/static/src/bundle/dashboard_badge.xml:0 +msgid "Active shares" +msgstr "" + +#. module: spreadsheet_dashboard_share_oca +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_share_oca/static/src/bundle/share_manage/share_manage_dialog.xml:0 +msgid "Close" +msgstr "" + +#. module: spreadsheet_dashboard_share_oca +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_share_oca/static/src/bundle/share_manage/share_manage_dialog.esm.js:0 +msgid "Copied" +msgstr "" + +#. module: spreadsheet_dashboard_share_oca +#: model:ir.model,name:spreadsheet_dashboard_share_oca.model_spreadsheet_dashboard_share +msgid "Copy of a shared dashboard" +msgstr "" + +#. module: spreadsheet_dashboard_share_oca +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_share_oca/static/src/bundle/share_manage/share_manage_dialog.xml:0 +msgid "Created by" +msgstr "" + +#. module: spreadsheet_dashboard_share_oca +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_share_oca/static/src/bundle/share_manage/share_manage_dialog.xml:0 +msgid "Created on" +msgstr "" + +#. module: spreadsheet_dashboard_share_oca +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_share_oca/static/src/bundle/share_manage/share_manage_dialog.xml:0 +msgid "Dashboard" +msgstr "" + +#. module: spreadsheet_dashboard_share_oca +#: model:ir.model.fields,field_description:spreadsheet_dashboard_share_oca.field_spreadsheet_dashboard_share__display_name +msgid "Display Name" +msgstr "" + +#. module: spreadsheet_dashboard_share_oca +#: model:ir.model.fields,field_description:spreadsheet_dashboard_share_oca.field_spreadsheet_dashboard_share__id +msgid "ID" +msgstr "" + +#. module: spreadsheet_dashboard_share_oca +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_share_oca/static/src/bundle/share_button.xml:0 +#: code:addons/spreadsheet_dashboard_share_oca/static/src/bundle/share_button_patch.esm.js:0 +msgid "Manage shares" +msgstr "" + +#. module: spreadsheet_dashboard_share_oca +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_share_oca/static/src/bundle/share_manage/share_manage_dialog.xml:0 +msgid "No shares yet." +msgstr "" + +#. module: spreadsheet_dashboard_share_oca +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_share_oca/static/src/bundle/share_manage/share_manage_dialog.xml:0 +msgid "Revoke" +msgstr "" + +#. module: spreadsheet_dashboard_share_oca +#. odoo-javascript +#: code:addons/spreadsheet_dashboard_share_oca/static/src/bundle/share_manage/share_manage_dialog.xml:0 +msgid "Share link" +msgstr "" diff --git a/spreadsheet_dashboard_share_oca/models/__init__.py b/spreadsheet_dashboard_share_oca/models/__init__.py new file mode 100644 index 00000000..6a1d1be6 --- /dev/null +++ b/spreadsheet_dashboard_share_oca/models/__init__.py @@ -0,0 +1 @@ +from . import spreadsheet_dashboard_share diff --git a/spreadsheet_dashboard_share_oca/models/spreadsheet_dashboard_share.py b/spreadsheet_dashboard_share_oca/models/spreadsheet_dashboard_share.py new file mode 100644 index 00000000..d0ffdecb --- /dev/null +++ b/spreadsheet_dashboard_share_oca/models/spreadsheet_dashboard_share.py @@ -0,0 +1,39 @@ +# Copyright 2026 Volkan Tasci +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class SpreadsheetDashboardShare(models.Model): + _inherit = "spreadsheet.dashboard.share" + + @api.model + def action_get_dashboard_shares(self, dashboard_id): + """Return the shares of a dashboard the current user can see.""" + shares = self.search([("dashboard_id", "=", dashboard_id)]) + return [ + { + "id": share.id, + "full_url": share.full_url, + "create_date": ( + share.create_date.isoformat() if share.create_date else False + ), + "create_uid": ( + share.create_uid.display_name if share.create_uid else False + ), + "name": share.name, + } + for share in shares + ] + + @api.model + def action_unshare(self, share_ids): + """Revoke shares. ir.rule limits the user to the shares they may access.""" + self.browse(share_ids).unlink() + return True + + @api.model + def action_get_share_counts(self): + """Return {dashboard_id: share_count} for shares visible to the user.""" + counts = self._read_group([], ["dashboard_id"], ["__count"]) + return {count[0].id: count[1] for count in counts if count[0]} diff --git a/spreadsheet_dashboard_share_oca/pyproject.toml b/spreadsheet_dashboard_share_oca/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/spreadsheet_dashboard_share_oca/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spreadsheet_dashboard_share_oca/readme/CONFIGURATION.md b/spreadsheet_dashboard_share_oca/readme/CONFIGURATION.md new file mode 100644 index 00000000..c80daf35 --- /dev/null +++ b/spreadsheet_dashboard_share_oca/readme/CONFIGURATION.md @@ -0,0 +1,3 @@ +Users can only see and revoke the shares they created. Users in the +"Dashboards / Admin" group (spreadsheet_dashboard.group_dashboard_manager) +can see and revoke the shares created by any user. diff --git a/spreadsheet_dashboard_share_oca/readme/SUMMARY.md b/spreadsheet_dashboard_share_oca/readme/SUMMARY.md new file mode 100644 index 00000000..4fd768b7 --- /dev/null +++ b/spreadsheet_dashboard_share_oca/readme/SUMMARY.md @@ -0,0 +1,3 @@ +This module allows users to manage the public share links of their +spreadsheet dashboards: list, copy, and revoke them. Dashboard Admins can +manage the shares created by any user. diff --git a/spreadsheet_dashboard_share_oca/readme/USAGE.md b/spreadsheet_dashboard_share_oca/readme/USAGE.md new file mode 100644 index 00000000..0b40eae1 --- /dev/null +++ b/spreadsheet_dashboard_share_oca/readme/USAGE.md @@ -0,0 +1,7 @@ +- Open the "Dashboards" app (Rapor Panelleri). +- Open a dashboard and click on the **Share** button. +- Click on **Manage shares** to list every share link of the dashboard. +- Use the copy button to copy a link, or click **Revoke** to make a link + return a 404 error. +- A badge next to the dashboard name in the sidebar shows the number of + active shares. diff --git a/spreadsheet_dashboard_share_oca/security/ir_rule.xml b/spreadsheet_dashboard_share_oca/security/ir_rule.xml new file mode 100644 index 00000000..c891e454 --- /dev/null +++ b/spreadsheet_dashboard_share_oca/security/ir_rule.xml @@ -0,0 +1,12 @@ + + + + spreadsheet.dashboard.share: manager sees all + + + [(1, '=', 1)] + + diff --git a/spreadsheet_dashboard_share_oca/static/src/bundle/dashboard_badge.esm.js b/spreadsheet_dashboard_share_oca/static/src/bundle/dashboard_badge.esm.js new file mode 100644 index 00000000..40e53c68 --- /dev/null +++ b/spreadsheet_dashboard_share_oca/static/src/bundle/dashboard_badge.esm.js @@ -0,0 +1,34 @@ +import {onWillStart, useState} from "@odoo/owl"; +import {SpreadsheetDashboardAction} from "@spreadsheet_dashboard/bundle/dashboard_action/dashboard_action"; +import {patch} from "@web/core/utils/patch"; +import {useBus} from "@web/core/utils/hooks"; + +patch(SpreadsheetDashboardAction.prototype, { + setup() { + super.setup(); + this.shareCounts = useState({}); + onWillStart(async () => { + await this._loadShareCounts(); + }); + useBus(this.env.bus, "dashboards-shares-updated", () => + this._loadShareCounts() + ); + }, + async _loadShareCounts() { + try { + const counts = await this.orm.call( + "spreadsheet.dashboard.share", + "action_get_share_counts", + [] + ); + Object.assign(this.shareCounts, counts); + } catch { + // Keep previous counts on failure + } + }, + async shareSpreadsheet(data, excelExport) { + const url = await super.shareSpreadsheet(data, excelExport); + this.env.bus.trigger("dashboards-shares-updated"); + return url; + }, +}); diff --git a/spreadsheet_dashboard_share_oca/static/src/bundle/dashboard_badge.xml b/spreadsheet_dashboard_share_oca/static/src/bundle/dashboard_badge.xml new file mode 100644 index 00000000..136565a6 --- /dev/null +++ b/spreadsheet_dashboard_share_oca/static/src/bundle/dashboard_badge.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + diff --git a/spreadsheet_dashboard_share_oca/static/src/bundle/share_button.xml b/spreadsheet_dashboard_share_oca/static/src/bundle/share_button.xml new file mode 100644 index 00000000..df9d4613 --- /dev/null +++ b/spreadsheet_dashboard_share_oca/static/src/bundle/share_button.xml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/spreadsheet_dashboard_share_oca/static/src/bundle/share_button_patch.esm.js b/spreadsheet_dashboard_share_oca/static/src/bundle/share_button_patch.esm.js new file mode 100644 index 00000000..5a406903 --- /dev/null +++ b/spreadsheet_dashboard_share_oca/static/src/bundle/share_button_patch.esm.js @@ -0,0 +1,30 @@ +import {SpreadsheetShareButton} from "@spreadsheet/components/share_button/share_button"; +import {_t} from "@web/core/l10n/translation"; +import {patch} from "@web/core/utils/patch"; +import {useService} from "@web/core/utils/hooks"; + +import {ShareManageDialog} from "./share_manage/share_manage_dialog.esm"; + +const MANAGE_SHARES = _t("Manage shares"); + +patch(SpreadsheetShareButton.prototype, { + setup() { + super.setup(); + this.dialog = useService("dialog"); + this.isDashboard = Boolean(this.env.services.spreadsheet_dashboard_loader); + }, + onManageShares() { + if (!this.isDashboard) { + return; + } + const dashboard = + this.env.services.spreadsheet_dashboard_loader.getActiveDashboard(); + if (!dashboard) { + return; + } + this.dialog.add(ShareManageDialog, { + dashboardId: dashboard.data.id, + title: `${dashboard.data.name} - ${MANAGE_SHARES}`, + }); + }, +}); diff --git a/spreadsheet_dashboard_share_oca/static/src/bundle/share_manage/share_manage_dialog.esm.js b/spreadsheet_dashboard_share_oca/static/src/bundle/share_manage/share_manage_dialog.esm.js new file mode 100644 index 00000000..5ff1e07e --- /dev/null +++ b/spreadsheet_dashboard_share_oca/static/src/bundle/share_manage/share_manage_dialog.esm.js @@ -0,0 +1,48 @@ +import {Component, onWillStart, useState} from "@odoo/owl"; +import {CopyButton} from "@web/core/copy_button/copy_button"; +import {Dialog} from "@web/core/dialog/dialog"; +import {_t} from "@web/core/l10n/translation"; +import {useService} from "@web/core/utils/hooks"; + +export class ShareManageDialog extends Component { + static template = "spreadsheet_dashboard_share_oca.ShareManageDialog"; + static components = {CopyButton, Dialog}; + static props = { + dashboardId: {type: Number}, + title: {type: String}, + close: {type: Function, optional: true}, + }; + + setup() { + this.copiedText = _t("Copied"); + this.orm = useService("orm"); + this.state = useState({shares: [], revoking: 0}); + onWillStart(async () => { + await this._loadShares(); + }); + } + + async _loadShares() { + this.state.shares = await this.orm.call( + "spreadsheet.dashboard.share", + "action_get_dashboard_shares", + [this.props.dashboardId] + ); + } + + async onRevoke(share) { + if (this.state.revoking === share.id) { + return; + } + this.state.revoking = share.id; + try { + await this.orm.call("spreadsheet.dashboard.share", "action_unshare", [ + [share.id], + ]); + this.state.shares = this.state.shares.filter((s) => s.id !== share.id); + this.env.bus.trigger("dashboards-shares-updated"); + } finally { + this.state.revoking = 0; + } + } +} diff --git a/spreadsheet_dashboard_share_oca/static/src/bundle/share_manage/share_manage_dialog.xml b/spreadsheet_dashboard_share_oca/static/src/bundle/share_manage/share_manage_dialog.xml new file mode 100644 index 00000000..4bcc9042 --- /dev/null +++ b/spreadsheet_dashboard_share_oca/static/src/bundle/share_manage/share_manage_dialog.xml @@ -0,0 +1,53 @@ + + + + +

+ No shares yet. +

+ + + + + + + + + + + + + + + + +
DashboardShare linkCreated byCreated onActions
+ +
+ + +
+
+ + + +
+ + + +
+
+
diff --git a/spreadsheet_dashboard_share_oca/static/tests/share_manage_dialog.test.js b/spreadsheet_dashboard_share_oca/static/tests/share_manage_dialog.test.js new file mode 100644 index 00000000..8ce57e97 --- /dev/null +++ b/spreadsheet_dashboard_share_oca/static/tests/share_manage_dialog.test.js @@ -0,0 +1,79 @@ +import { + contains, + makeDialogMockEnv, + mountWithCleanup, + patchWithCleanup, +} from "@web/../tests/web_test_helpers"; +import {describe, expect, test} from "@odoo/hoot"; + +import {ShareManageDialog} from "@spreadsheet_dashboard_share_oca/bundle/share_manage/share_manage_dialog"; + +describe.current.tags("desktop"); + +const SHARES = [ + { + id: 1, + full_url: "http://localhost/dashboard/share/1/token1", + create_date: "2026-08-04T10:00:00", + create_uid: "Raoul", + name: "DeepSeek API Giderleri", + }, + { + id: 2, + full_url: "http://localhost/dashboard/share/2/token2", + create_date: "2026-08-04T11:00:00", + create_uid: "Bob", + name: "DeepSeek API Giderleri", + }, +]; + +async function mountDialog(env, {shares = SHARES} = {}) { + patchWithCleanup(env.services.orm, { + async call(model, method) { + if (method === "action_get_dashboard_shares") { + return shares; + } + if (method === "action_unshare") { + return true; + } + throw new Error(`Unexpected method: ${method}`); + }, + }); + await mountWithCleanup(ShareManageDialog, { + env, + props: { + dashboardId: 3, + title: "DeepSeek API Giderleri - Manage shares", + }, + }); +} + +test("renders the share list", async () => { + const env = await makeDialogMockEnv(); + await mountDialog(env); + expect(".modal-header").toHaveText(/Manage shares/); + expect("tbody tr").toHaveCount(2); + expect("tbody tr").toHaveText(/Raoul/); + expect("tbody tr").toHaveText(/Bob/); + expect("tbody tr").toHaveText(/dashboard\/share\/1\/token1/); +}); + +test("shows empty state when there are no shares", async () => { + const env = await makeDialogMockEnv(); + await mountDialog(env, {shares: []}); + expect("tbody").toHaveCount(0); + expect("body").toHaveText(/No shares yet/); +}); + +test("revoke removes the row and notifies the badge", async () => { + const env = await makeDialogMockEnv(); + let busEvents = 0; + env.bus.addEventListener("dashboards-shares-updated", () => busEvents++); + await mountDialog(env); + expect("tbody tr").toHaveCount(2); + await contains(".btn-danger").click(); + expect("tbody tr").toHaveCount(1); + expect("tbody tr").toHaveText(/Bob/); + expect("tbody tr").toHaveText(/dashboard\/share\/2\/token2/); + expect(busEvents).toBe(1); +}); diff --git a/spreadsheet_dashboard_share_oca/tests/__init__.py b/spreadsheet_dashboard_share_oca/tests/__init__.py new file mode 100644 index 00000000..538fcda5 --- /dev/null +++ b/spreadsheet_dashboard_share_oca/tests/__init__.py @@ -0,0 +1 @@ +from . import test_share_management diff --git a/spreadsheet_dashboard_share_oca/tests/test_share_management.py b/spreadsheet_dashboard_share_oca/tests/test_share_management.py new file mode 100644 index 00000000..495a48b7 --- /dev/null +++ b/spreadsheet_dashboard_share_oca/tests/test_share_management.py @@ -0,0 +1,146 @@ +# Copyright 2026 Volkan Tasci +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.exceptions import AccessError +from odoo.tests import HttpCase, new_test_user + +from odoo.addons.spreadsheet_dashboard.tests.common import DashboardTestCommon + + +class TestShareManagement(DashboardTestCommon, HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user2 = new_test_user(cls.env, login="Bob") + cls.user2.group_ids |= cls.group + cls.manager = new_test_user( + cls.env, + login="Manager", + groups="spreadsheet_dashboard.group_dashboard_manager", + ) + + def _share_as(self, user, dashboard): + return ( + self.env["spreadsheet.dashboard.share"] + .with_user(user) + .create( + { + "dashboard_id": dashboard.id, + "spreadsheet_data": dashboard.spreadsheet_data, + } + ) + ) + + def test_list_only_own_shares(self): + dashboard = self.create_dashboard() + share_raoul = self._share_as(self.user, dashboard) + share_bob = self._share_as(self.user2, dashboard) + + shares = ( + self.env["spreadsheet.dashboard.share"] + .with_user(self.user) + .action_get_dashboard_shares(dashboard.id) + ) + self.assertEqual(len(shares), 1) + self.assertEqual(shares[0]["id"], share_raoul.id) + self.assertEqual(shares[0]["full_url"], share_raoul.full_url) + self.assertEqual(shares[0]["name"], dashboard.name) + self.assertEqual(shares[0]["create_uid"], self.user.display_name) + self.assertTrue(shares[0]["create_date"]) + self.assertNotEqual(shares[0]["id"], share_bob.id) + + def test_manager_sees_all_shares(self): + dashboard = self.create_dashboard() + share_raoul = self._share_as(self.user, dashboard) + share_bob = self._share_as(self.user2, dashboard) + + shares = ( + self.env["spreadsheet.dashboard.share"] + .with_user(self.manager) + .action_get_dashboard_shares(dashboard.id) + ) + self.assertEqual( + {share["id"] for share in shares}, {share_raoul.id, share_bob.id} + ) + + def test_unshare_own_share(self): + dashboard = self.create_dashboard() + share = self._share_as(self.user, dashboard) + + result = ( + self.env["spreadsheet.dashboard.share"] + .with_user(self.user) + .action_unshare([share.id]) + ) + self.assertTrue(result) + self.assertFalse( + self.env["spreadsheet.dashboard.share"].search([("id", "=", share.id)]) + ) + + def test_cannot_unshare_other_users_share(self): + dashboard = self.create_dashboard() + share = self._share_as(self.user2, dashboard) + + with self.assertRaises(AccessError): + ( + self.env["spreadsheet.dashboard.share"] + .with_user(self.user) + .action_unshare([share.id]) + ) + + def test_manager_can_unshare_other_users_share(self): + dashboard = self.create_dashboard() + share = self._share_as(self.user2, dashboard) + + result = ( + self.env["spreadsheet.dashboard.share"] + .with_user(self.manager) + .action_unshare([share.id]) + ) + self.assertTrue(result) + self.assertFalse( + self.env["spreadsheet.dashboard.share"].search([("id", "=", share.id)]) + ) + + def test_unshared_link_returns_404(self): + dashboard = self.create_dashboard() + share = self._share_as(self.user, dashboard) + access_token = share.access_token + share.with_user(self.user).action_unshare([share.id]) + + response = self.url_open(f"/dashboard/share/{share.id}/{access_token}") + self.assertEqual(response.status_code, 404) + + def test_share_counts_own_shares_only(self): + dashboard = self.create_dashboard() + self._share_as(self.user, dashboard) + self._share_as(self.user2, dashboard) + + counts = ( + self.env["spreadsheet.dashboard.share"] + .with_user(self.user) + .action_get_share_counts() + ) + self.assertEqual(counts[dashboard.id], 1) + + def test_share_counts_manager_sees_all(self): + dashboard = self.create_dashboard() + self._share_as(self.user, dashboard) + self._share_as(self.user2, dashboard) + + counts = ( + self.env["spreadsheet.dashboard.share"] + .with_user(self.manager) + .action_get_share_counts() + ) + self.assertEqual(counts[dashboard.id], 2) + + def test_share_counts_empty_for_dashboard_without_shares(self): + dashboard = self.create_dashboard() + + counts = ( + self.env["spreadsheet.dashboard.share"] + .with_user(self.manager) + .action_get_share_counts() + ) + self.assertNotIn(dashboard.id, counts)