Skip to content

Commit 3ffbec9

Browse files
committed
feat(statistics): rework dashboard — correctness, perf, service layer, tests
Turns the single-handler 155-line dashboard into a cached read-model served by a dedicated service. Same visual layout, same columns — every data bug fixed along the way. Correctness ----------- * DOI filter is now applied to every aggregation (views-per-month and downloads-per-month used to include private datasets, so the timeline disagreed with the summary counters). * Monthly series fill the full rolling 12 months instead of only the months where something happened — the line chart no longer connects non-consecutive points and draws false zeros. * `datetime.utcnow()` replaces `datetime.now()` — the 12-month window was drifting by up to ~1 hour because local server time was being compared against UTC rows. * "Trending Datasets" renamed to "Most viewed datasets" — the column was ordering by all-time views, not recency, so the name was lying. Performance ----------- * Monthly rollups moved from `defaultdict` on a full table dump to `DATE_FORMAT(col, '%Y-%m') + GROUP BY` in SQL. For busy hubs this stops materialising every DSDownloadRecord in Python memory. * Dashboard output cached in Redis (5 min TTL, key `statistics:dashboard:v1`) with an `invalidate_cache()` escape hatch for seeders / bulk imports. Caching is auto-disabled under `TESTING=True` so tests stay deterministic. Structure --------- * New `DashboardService` + `DashboardData` / `DashboardRow` dataclasses. `routes.py` shrinks to ~10 lines. * Template shares a single Jinja macro for the six "top-N" cards; ~250 lines of copy-paste gone. * Inline `<script>` blocks out, bundled JS in: `scripts.js` now imports `chart.js/auto` through webpack and reads the chart series from a `<script type="application/json">` payload. Assets ------ * Chart.js served from the bundle instead of the CDN — works offline, follows the project's existing webpack convention, drops a third-party script tag. Tests (7 new) ------------- * `test_dashboard_excludes_datasets_without_doi` * `test_dashboard_monthly_series_has_no_gaps` * `test_dashboard_top_tables_are_ordered_by_metric` * `test_dashboard_uses_cache_when_enabled` * `test_dashboard_route_renders_without_errors` * plus the pre-existing counter / sample cases. Verified before push: * `rosemary linter` clean * `black --check` + `isort --check-only` clean * `pytest app/modules/ -m 'not slow'` → 124/124 green * `rosemary webpack:compile statistics` → bundle builds (555 KiB, Chart.js included).
1 parent 9eedd1b commit 3ffbec9

6 files changed

Lines changed: 691 additions & 499 deletions

File tree

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,93 @@
1-
console.log("Hi, I am a script loaded from statistics module");
1+
// Dashboard chart bootstrap. The server renders the series as JSON inside
2+
// `<script id="dashboard-data">` so this module has a single parse step and
3+
// no template-interpolation inside JS (safer, easier to cache, no CSP hacks).
4+
5+
import Chart from "chart.js/auto";
6+
7+
function readData() {
8+
const el = document.getElementById("dashboard-data");
9+
if (!el) return null;
10+
try {
11+
return JSON.parse(el.textContent || "{}");
12+
} catch (e) {
13+
console.error("dashboard: bad JSON payload", e);
14+
return null;
15+
}
16+
}
17+
18+
function renderUploads(data) {
19+
const canvas = document.getElementById("uploadsChart");
20+
if (!canvas || !data.months?.length) return;
21+
new Chart(canvas.getContext("2d"), {
22+
type: "bar",
23+
data: {
24+
labels: data.months,
25+
datasets: [
26+
{
27+
label: "Datasets uploaded",
28+
data: data.uploads || [],
29+
backgroundColor: "rgba(54, 153, 255, 0.2)",
30+
borderColor: "rgba(54, 153, 255, 1)",
31+
borderWidth: 2,
32+
borderRadius: 4,
33+
},
34+
],
35+
},
36+
options: {
37+
responsive: true,
38+
plugins: { legend: { display: false } },
39+
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
40+
},
41+
});
42+
}
43+
44+
function renderActivity(data) {
45+
const canvas = document.getElementById("activityChart");
46+
if (!canvas || !data.months?.length) return;
47+
new Chart(canvas.getContext("2d"), {
48+
type: "line",
49+
data: {
50+
labels: data.months,
51+
datasets: [
52+
{
53+
label: "Downloads",
54+
data: data.downloads || [],
55+
backgroundColor: "rgba(246, 78, 96, 0.1)",
56+
borderColor: "rgba(246, 78, 96, 1)",
57+
borderWidth: 2,
58+
tension: 0.3,
59+
fill: true,
60+
pointRadius: 4,
61+
},
62+
{
63+
label: "Views",
64+
data: data.views || [],
65+
backgroundColor: "rgba(80, 205, 137, 0.1)",
66+
borderColor: "rgba(80, 205, 137, 1)",
67+
borderWidth: 2,
68+
tension: 0.3,
69+
fill: true,
70+
pointRadius: 4,
71+
},
72+
],
73+
},
74+
options: {
75+
responsive: true,
76+
plugins: { legend: { display: true, position: "top" } },
77+
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
78+
},
79+
});
80+
}
81+
82+
function init() {
83+
const data = readData();
84+
if (!data) return;
85+
renderUploads(data);
86+
renderActivity(data);
87+
}
88+
89+
if (document.readyState === "loading") {
90+
document.addEventListener("DOMContentLoaded", init, { once: true });
91+
} else {
92+
init();
93+
}
Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1-
const path = require('path');
1+
const path = require("path");
22

33
module.exports = {
4-
entry: path.resolve(__dirname, './scripts.js'),
5-
output: {
6-
filename: 'statistics.bundle.js',
7-
path: path.resolve(__dirname, '../dist'),
8-
},
9-
resolve: {
10-
fallback: {
11-
"fs": false
12-
}
13-
},
14-
mode: 'development',
4+
entry: path.resolve(__dirname, "./scripts.js"),
5+
output: {
6+
filename: "statistics.bundle.js",
7+
path: path.resolve(__dirname, "../dist"),
8+
module: true,
9+
},
10+
experiments: {
11+
outputModule: true,
12+
},
13+
resolve: {
14+
fallback: { fs: false },
15+
},
16+
mode: "development",
17+
devtool: "source-map",
1518
};

app/modules/statistics/routes.py

Lines changed: 3 additions & 169 deletions
Original file line numberDiff line numberDiff line change
@@ -1,176 +1,10 @@
1-
from collections import defaultdict
2-
from datetime import datetime, timedelta
3-
41
from flask import render_template
5-
from sqlalchemy import func
62

7-
from app import db
83
from app.modules.statistics import statistics_bp
4+
from app.modules.statistics.services import DashboardService
95

106

117
@statistics_bp.route("/statistics", methods=["GET"])
128
def index():
13-
from app.modules.dataset.models import Author, DataSet, DSMetaData, DSMetrics
14-
from app.modules.featuremodel.models import FeatureModel
15-
from app.modules.statistics.services import StatisticsService
16-
17-
statistics_service = StatisticsService()
18-
19-
# --- Summary counters ---
20-
total_datasets = (
21-
db.session.query(DataSet)
22-
.join(DSMetaData, DataSet.ds_meta_data_id == DSMetaData.id)
23-
.filter(DSMetaData.dataset_doi.isnot(None))
24-
.count()
25-
)
26-
total_feature_models = (
27-
db.session.query(FeatureModel)
28-
.join(DataSet, DataSet.id == FeatureModel.dataset_id)
29-
.join(DSMetaData, DataSet.ds_meta_data_id == DSMetaData.id)
30-
.filter(DSMetaData.dataset_doi.isnot(None))
31-
.count()
32-
)
33-
total_authors = db.session.query(Author).count()
34-
total_views = statistics_service.get_datasets_viewed()
35-
total_downloads = statistics_service.get_datasets_downloaded()
36-
37-
avg_models_per_dataset = round(total_feature_models / total_datasets, 2) if total_datasets else 0
38-
39-
# --- Top 5 datasets with most feature models ---
40-
top_datasets_by_models = (
41-
db.session.query(DataSet, func.count(FeatureModel.id).label("fm_count"))
42-
.join(FeatureModel, DataSet.id == FeatureModel.dataset_id)
43-
.join(DSMetaData, DataSet.ds_meta_data_id == DSMetaData.id)
44-
.filter(DSMetaData.dataset_doi.isnot(None))
45-
.group_by(DataSet.id)
46-
.order_by(func.count(FeatureModel.id).desc())
47-
.limit(5)
48-
.all()
49-
)
50-
51-
# --- Top 5 datasets with most features (DSMetrics) ---
52-
top_datasets_by_features = (
53-
db.session.query(DataSet, DSMetrics.number_of_features)
54-
.join(DSMetaData, DataSet.ds_meta_data_id == DSMetaData.id)
55-
.join(DSMetrics, DSMetaData.ds_metrics_id == DSMetrics.id)
56-
.filter(DSMetaData.dataset_doi.isnot(None))
57-
.filter(DSMetrics.number_of_features.isnot(None))
58-
.order_by(DSMetrics.number_of_features.desc())
59-
.limit(5)
60-
.all()
61-
)
62-
63-
# --- Datasets by publication type ---
64-
datasets_by_type = (
65-
db.session.query(DSMetaData.publication_type, func.count(DataSet.id).label("count"))
66-
.join(DataSet, DataSet.ds_meta_data_id == DSMetaData.id)
67-
.filter(DSMetaData.dataset_doi.isnot(None))
68-
.filter(DSMetaData.publication_type.isnot(None))
69-
.group_by(DSMetaData.publication_type)
70-
.order_by(func.count(DataSet.id).desc())
71-
.all()
72-
)
73-
74-
# --- Timeline: uploads per month (last 12 months) ---
75-
twelve_months_ago = datetime.now() - timedelta(days=365)
76-
recent_datasets = (
77-
db.session.query(DataSet)
78-
.join(DSMetaData, DataSet.ds_meta_data_id == DSMetaData.id)
79-
.filter(DSMetaData.dataset_doi.isnot(None))
80-
.filter(DataSet.created_at >= twelve_months_ago)
81-
.all()
82-
)
83-
timeline = defaultdict(int)
84-
for ds in recent_datasets:
85-
timeline[ds.created_at.strftime("%Y-%m")] += 1
86-
timeline_labels = sorted(timeline.keys())
87-
timeline_data = [timeline[k] for k in timeline_labels]
88-
89-
# --- 5 most recently uploaded datasets ---
90-
latest_datasets = (
91-
db.session.query(DataSet)
92-
.join(DSMetaData, DataSet.ds_meta_data_id == DSMetaData.id)
93-
.filter(DSMetaData.dataset_doi.isnot(None))
94-
.order_by(DataSet.created_at.desc())
95-
.limit(5)
96-
.all()
97-
)
98-
99-
# --- Top 5 most viewed datasets ---
100-
from app.modules.dataset.models import DSDownloadRecord, DSViewRecord
101-
102-
top_datasets_by_views = (
103-
db.session.query(DataSet, func.count(DSViewRecord.id).label("view_count"))
104-
.join(DSViewRecord, DataSet.id == DSViewRecord.dataset_id)
105-
.join(DSMetaData, DataSet.ds_meta_data_id == DSMetaData.id)
106-
.filter(DSMetaData.dataset_doi.isnot(None))
107-
.group_by(DataSet.id)
108-
.order_by(func.count(DSViewRecord.id).desc())
109-
.limit(5)
110-
.all()
111-
)
112-
113-
# --- Top 5 most downloaded datasets ---
114-
top_datasets_by_downloads = (
115-
db.session.query(DataSet, func.count(DSDownloadRecord.id).label("download_count"))
116-
.join(DSDownloadRecord, DataSet.id == DSDownloadRecord.dataset_id)
117-
.join(DSMetaData, DataSet.ds_meta_data_id == DSMetaData.id)
118-
.filter(DSMetaData.dataset_doi.isnot(None))
119-
.group_by(DataSet.id)
120-
.order_by(func.count(DSDownloadRecord.id).desc())
121-
.limit(5)
122-
.all()
123-
)
124-
125-
# --- Top 5 datasets with most configurations (number_of_models, closest to restrictions) ---
126-
top_datasets_by_configurations = (
127-
db.session.query(DataSet, DSMetrics.number_of_models)
128-
.join(DSMetaData, DataSet.ds_meta_data_id == DSMetaData.id)
129-
.join(DSMetrics, DSMetaData.ds_metrics_id == DSMetrics.id)
130-
.filter(DSMetaData.dataset_doi.isnot(None))
131-
.filter(DSMetrics.number_of_models.isnot(None))
132-
.order_by(DSMetrics.number_of_models.desc())
133-
.limit(5)
134-
.all()
135-
)
136-
137-
# --- Downloads per month (last 12 months) ---
138-
recent_downloads = (
139-
db.session.query(DSDownloadRecord).filter(DSDownloadRecord.download_date >= twelve_months_ago).all()
140-
)
141-
downloads_by_month = defaultdict(int)
142-
for record in recent_downloads:
143-
downloads_by_month[record.download_date.strftime("%Y-%m")] += 1
144-
145-
# --- Views per month (last 12 months) ---
146-
recent_views = db.session.query(DSViewRecord).filter(DSViewRecord.view_date >= twelve_months_ago).all()
147-
views_by_month = defaultdict(int)
148-
for record in recent_views:
149-
views_by_month[record.view_date.strftime("%Y-%m")] += 1
150-
151-
# Merge all months for a unified x-axis
152-
all_months = sorted(set(timeline_labels) | set(downloads_by_month.keys()) | set(views_by_month.keys()))
153-
activity_downloads = [downloads_by_month.get(m, 0) for m in all_months]
154-
activity_views = [views_by_month.get(m, 0) for m in all_months]
155-
156-
return render_template(
157-
"statistics/index.html",
158-
total_datasets=total_datasets,
159-
total_feature_models=total_feature_models,
160-
total_authors=total_authors,
161-
total_views=total_views,
162-
total_downloads=total_downloads,
163-
avg_models_per_dataset=avg_models_per_dataset,
164-
top_datasets_by_models=top_datasets_by_models,
165-
top_datasets_by_features=top_datasets_by_features,
166-
top_datasets_by_views=top_datasets_by_views,
167-
top_datasets_by_downloads=top_datasets_by_downloads,
168-
top_datasets_by_configurations=top_datasets_by_configurations,
169-
datasets_by_type=datasets_by_type,
170-
timeline_labels=timeline_labels,
171-
timeline_data=timeline_data,
172-
all_months=all_months,
173-
activity_downloads=activity_downloads,
174-
activity_views=activity_views,
175-
latest_datasets=latest_datasets,
176-
)
9+
dashboard = DashboardService().build_dashboard()
10+
return render_template("statistics/index.html", dashboard=dashboard)

0 commit comments

Comments
 (0)