Skip to content

Commit e2e3d50

Browse files
committed
Merge branch 'develop' into main
2 parents 86f79c6 + 73246fc commit e2e3d50

5 files changed

Lines changed: 211 additions & 41 deletions

File tree

app/modules/statistics/repositories.py

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -62,26 +62,61 @@ def get_feature_models_downloaded(self) -> int:
6262
statistics = self.get_statistics()
6363
return statistics.feature_models_downloaded
6464

65-
def refresh_statistics(self) -> Statistics:
65+
def compute_totals(self) -> dict[str, int]:
66+
"""Re-compute every counter from the record tables without touching
67+
the persisted row. Every total is restricted to datasets with a DOI
68+
so the counters line up with what the `/statistics` dashboard shows.
69+
"""
6670
from app.modules.dataset.models import DataSet, DSMetaData
6771
from app.modules.featuremodel.models import FeatureModel
6872

69-
statistics = self.get_statistics()
70-
7173
synchronized_dataset_ids = (
7274
self.session.query(DataSet.id).join(DSMetaData).filter(DSMetaData.dataset_doi.isnot(None)).scalar_subquery()
7375
)
74-
75-
statistics.datasets_counter = (
76-
self.session.query(DataSet).join(DSMetaData).filter(DSMetaData.dataset_doi.isnot(None)).count()
76+
synchronized_featuremodel_ids = (
77+
self.session.query(FeatureModel.id)
78+
.filter(FeatureModel.dataset_id.in_(synchronized_dataset_ids))
79+
.scalar_subquery()
7780
)
78-
statistics.feature_models_counter = (
79-
self.session.query(FeatureModel).filter(FeatureModel.dataset_id.in_(synchronized_dataset_ids)).count()
81+
from app.modules.hubfile.models import Hubfile
82+
83+
synchronized_hubfile_ids = (
84+
self.session.query(Hubfile.id)
85+
.filter(Hubfile.feature_model_id.in_(synchronized_featuremodel_ids))
86+
.scalar_subquery()
8087
)
81-
statistics.datasets_viewed = self.session.query(DSViewRecord).count()
82-
statistics.feature_models_viewed = self.session.query(HubfileViewRecord).count()
83-
statistics.datasets_downloaded = self.session.query(DSDownloadRecord).count()
84-
statistics.feature_models_downloaded = self.session.query(HubfileDownloadRecord).count()
8588

89+
return {
90+
"datasets_counter": (
91+
self.session.query(DataSet).join(DSMetaData).filter(DSMetaData.dataset_doi.isnot(None)).count()
92+
),
93+
"feature_models_counter": (
94+
self.session.query(FeatureModel).filter(FeatureModel.dataset_id.in_(synchronized_dataset_ids)).count()
95+
),
96+
"datasets_viewed": (
97+
self.session.query(DSViewRecord).filter(DSViewRecord.dataset_id.in_(synchronized_dataset_ids)).count()
98+
),
99+
"datasets_downloaded": (
100+
self.session.query(DSDownloadRecord)
101+
.filter(DSDownloadRecord.dataset_id.in_(synchronized_dataset_ids))
102+
.count()
103+
),
104+
"feature_models_viewed": (
105+
self.session.query(HubfileViewRecord)
106+
.filter(HubfileViewRecord.file_id.in_(synchronized_hubfile_ids))
107+
.count()
108+
),
109+
"feature_models_downloaded": (
110+
self.session.query(HubfileDownloadRecord)
111+
.filter(HubfileDownloadRecord.file_id.in_(synchronized_hubfile_ids))
112+
.count()
113+
),
114+
}
115+
116+
def refresh_statistics(self) -> Statistics:
117+
"""Persist the recomputed totals into the singleton row."""
118+
statistics = self.get_statistics()
119+
for field, value in self.compute_totals().items():
120+
setattr(statistics, field, value)
86121
self.session.commit()
87122
return statistics

app/modules/statistics/services.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,17 @@ def get_feature_models_downloaded(self) -> int:
9797
def refresh_statistics(self) -> Statistics:
9898
return self.repository.refresh_statistics()
9999

100+
def preview_refresh(self) -> dict[str, tuple[int, int]]:
101+
"""Dry-run sync: return ``{field: (before, after)}`` without writing.
102+
103+
Used by `rosemary counters:sync --dry-run` so operators can inspect
104+
drift before committing. Every dict key is also a real field on
105+
:class:`Statistics`, so callers can feed it straight into a template.
106+
"""
107+
current = self.repository.get_statistics()
108+
proposed = self.repository.compute_totals()
109+
return {field: (getattr(current, field), value) for field, value in proposed.items()}
110+
100111

101112
# ─────────────────────────────────────────────────────────────────────────────
102113
# Dashboard service — all the aggregations in one place.

app/modules/statistics/tests/test_unit.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,82 @@ def test_refresh_statistics_rebuilds_counters_from_records(mock_enqueue_task, te
7878
assert statistics.feature_models_downloaded == 1
7979

8080

81+
# ─── DOI filter on refresh_statistics ──────────────────────────────────────
82+
83+
84+
@patch("core.managers.task_queue_manager.TaskQueueManager.enqueue_task")
85+
def test_refresh_statistics_excludes_private_datasets(mock_enqueue_task, test_client, clean_database):
86+
"""Views and downloads for datasets without a DOI must not be counted,
87+
otherwise the `/statistics` summary cards disagree with the top-N tables.
88+
"""
89+
user = UserRepository().create(email="stats-private@example.com", password="test1234")
90+
91+
public_meta = DSMetaDataRepository().create(
92+
title="Public",
93+
description="Public",
94+
publication_type=PublicationType.BOOK,
95+
dataset_doi="10.1234/public",
96+
)
97+
public_ds = DataSetRepository().create(user_id=user.id, ds_meta_data_id=public_meta.id)
98+
public_fm = FeatureModelRepository().create(dataset_id=public_ds.id)
99+
public_hubfile = HubfileRepository().create(
100+
name="public.uvl", checksum="pub", size=1, feature_model_id=public_fm.id
101+
)
102+
DSViewRecordRepository().create(dataset_id=public_ds.id, view_cookie="pub-v")
103+
DSDownloadRecordRepository().create(dataset_id=public_ds.id, download_cookie="pub-d")
104+
HubfileViewRecordRepository().create(file_id=public_hubfile.id, view_cookie="pub-fv")
105+
HubfileDownloadRecordRepository().create(file_id=public_hubfile.id, download_cookie="pub-fd")
106+
107+
private_meta = DSMetaDataRepository().create(
108+
title="Private",
109+
description="Private",
110+
publication_type=PublicationType.BOOK,
111+
dataset_doi=None,
112+
)
113+
private_ds = DataSetRepository().create(user_id=user.id, ds_meta_data_id=private_meta.id)
114+
private_fm = FeatureModelRepository().create(dataset_id=private_ds.id)
115+
private_hubfile = HubfileRepository().create(
116+
name="priv.uvl", checksum="priv", size=1, feature_model_id=private_fm.id
117+
)
118+
DSViewRecordRepository().create(dataset_id=private_ds.id, view_cookie="priv-v")
119+
DSDownloadRecordRepository().create(dataset_id=private_ds.id, download_cookie="priv-d")
120+
HubfileViewRecordRepository().create(file_id=private_hubfile.id, view_cookie="priv-fv")
121+
HubfileDownloadRecordRepository().create(file_id=private_hubfile.id, download_cookie="priv-fd")
122+
123+
stats = StatisticsService().refresh_statistics()
124+
125+
assert stats.datasets_counter == 1
126+
assert stats.feature_models_counter == 1
127+
assert stats.datasets_viewed == 1
128+
assert stats.datasets_downloaded == 1
129+
assert stats.feature_models_viewed == 1
130+
assert stats.feature_models_downloaded == 1
131+
132+
133+
def test_preview_refresh_returns_before_after_tuples(test_client, clean_database):
134+
user = UserRepository().create(email="preview@example.com", password="test1234")
135+
meta = DSMetaDataRepository().create(
136+
title="Preview",
137+
description="Preview",
138+
publication_type=PublicationType.BOOK,
139+
dataset_doi="10.9999/preview",
140+
)
141+
ds = DataSetRepository().create(user_id=user.id, ds_meta_data_id=meta.id)
142+
FeatureModelRepository().create(dataset_id=ds.id)
143+
144+
service = StatisticsService()
145+
diff = service.preview_refresh()
146+
147+
# Snapshot is untouched by a dry-run preview.
148+
persisted = service.get_statistics()
149+
assert persisted.datasets_counter == 0
150+
assert persisted.feature_models_counter == 0
151+
152+
# The diff maps each field to (before, after).
153+
assert diff["datasets_counter"] == (0, 1)
154+
assert diff["feature_models_counter"] == (0, 1)
155+
156+
81157
# ─── Dashboard ──────────────────────────────────────────────────────────────
82158

83159

rosemary/commands/counters_commands.py

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,69 @@
11
import click
22
from flask.cli import with_appcontext
33

4+
# Fields printed by the sync output, in the order we want them to appear.
5+
# Keys match `Statistics` model columns so the same list drives both the
6+
# live sync and the --dry-run diff.
7+
_FIELDS = [
8+
("datasets_counter", "Datasets (DOI-synced)"),
9+
("feature_models_counter", "Feature models (DOI-synced)"),
10+
("datasets_viewed", "Dataset views"),
11+
("feature_models_viewed", "Feature model views"),
12+
("datasets_downloaded", "Dataset downloads"),
13+
("feature_models_downloaded", "Feature model downloads"),
14+
]
15+
16+
17+
def _format_diff(before: int, after: int) -> str:
18+
delta = after - before
19+
if delta == 0:
20+
return click.style(f"{after}", fg="white")
21+
arrow = click.style(f"{before}{after}", fg="yellow")
22+
colour = "green" if delta > 0 else "red"
23+
sign = "+" if delta > 0 else ""
24+
return f"{arrow} ({click.style(f'{sign}{delta}', fg=colour)})"
25+
426

527
@click.command(
628
"counters:sync",
7-
help="Syncs all hub counters (datasets, feature models, views, downloads) from the database.",
29+
help=(
30+
"Recompute hub counters (datasets, feature models, views, downloads) "
31+
"from the record tables. Use --dry-run to preview drift without "
32+
"writing; the dashboard cache is invalidated on a successful commit."
33+
),
34+
)
35+
@click.option(
36+
"--dry-run",
37+
is_flag=True,
38+
default=False,
39+
help="Show what would change without touching the Statistics row or cache.",
840
)
941
@with_appcontext
10-
def counters_sync():
11-
from app.modules.statistics.services import StatisticsService
42+
def counters_sync(dry_run: bool):
43+
from app.modules.statistics.services import DashboardService, StatisticsService
1244

1345
service = StatisticsService()
1446

1547
try:
16-
statistics = service.refresh_statistics()
17-
18-
click.echo(click.style("Hub counters synced successfully.", fg="green"))
19-
click.echo(f" Datasets (synchronized): {statistics.datasets_counter}")
20-
click.echo(f" Feature models (synchronized):{statistics.feature_models_counter}")
21-
click.echo(f" Dataset views: {statistics.datasets_viewed}")
22-
click.echo(f" Feature model views: {statistics.feature_models_viewed}")
23-
click.echo(f" Dataset downloads: {statistics.datasets_downloaded}")
24-
click.echo(f" Feature model downloads: {statistics.feature_models_downloaded}")
48+
diff = service.preview_refresh()
49+
50+
if dry_run:
51+
click.echo(click.style("Dry-run — no changes persisted.", fg="cyan"))
52+
else:
53+
service.refresh_statistics()
54+
# Drop the cached dashboard so the next hit reflects the new totals.
55+
try:
56+
DashboardService().invalidate_cache()
57+
except Exception as exc: # pragma: no cover — best-effort cleanup
58+
click.echo(click.style(f"[WARN] Dashboard cache invalidate failed: {exc}", fg="yellow"))
59+
click.echo(click.style("Hub counters synced successfully.", fg="green"))
60+
61+
any_changes = any(before != after for before, after in diff.values())
62+
if any_changes:
63+
click.echo()
64+
for field, label in _FIELDS:
65+
before, after = diff.get(field, (0, 0))
66+
click.echo(f" {label:<30} {_format_diff(before, after)}")
2567

2668
except Exception as e:
2769
click.echo(click.style(f"[ERROR] Failed to sync counters: {e}", fg="red"))
Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,31 @@
11
import click
22
from flask.cli import with_appcontext
33

4+
from rosemary.commands.counters_commands import counters_sync
5+
46

57
@click.command(
68
"statistics:refresh",
7-
help="Recalculates persisted hub statistics from record tables.",
9+
help=(
10+
"DEPRECATED alias for `counters:sync`. Will be removed next release — "
11+
"use `rosemary counters:sync` (optionally with --dry-run) instead."
12+
),
13+
context_settings={"ignore_unknown_options": True},
14+
)
15+
@click.option(
16+
"--dry-run",
17+
is_flag=True,
18+
default=False,
19+
help="Forwarded to `counters:sync --dry-run`.",
820
)
21+
@click.pass_context
922
@with_appcontext
10-
def statistics_refresh():
11-
from app.modules.statistics.services import StatisticsService
12-
13-
service = StatisticsService()
14-
15-
try:
16-
statistics = service.refresh_statistics()
17-
18-
click.echo(click.style("Statistics refreshed successfully.", fg="green"))
19-
click.echo(f"Dataset views: {statistics.datasets_viewed}")
20-
click.echo(f"Model views: {statistics.feature_models_viewed}")
21-
click.echo(f"Dataset downloads: {statistics.datasets_downloaded}")
22-
click.echo(f"Model downloads: {statistics.feature_models_downloaded}")
23-
24-
except Exception as e:
25-
click.echo(click.style(f"[ERROR] Failed to refresh statistics: {e}", fg="red"))
23+
def statistics_refresh(ctx, dry_run: bool):
24+
click.echo(
25+
click.style(
26+
"DEPRECATED: `statistics:refresh` has been renamed to `counters:sync`. "
27+
"This alias will be removed next release.",
28+
fg="yellow",
29+
)
30+
)
31+
ctx.invoke(counters_sync, dry_run=dry_run)

0 commit comments

Comments
 (0)