diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8c9943..c08551c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,11 @@ on: paths: - '.github/workflows/**' - 'rdmo_plugins_statistics/**' + - 'scripts/**' + - 'tests/**' - .pre-commit-config.yaml - pyproject.toml + - README.md # Ref: https://docs.github.com/en/actions/using-jobs/using-concurrency concurrency: @@ -33,7 +36,7 @@ jobs: with: python-version: "3.12" cache: pip - - run: python -m pip install --upgrade pip setuptools wheel + - run: python -m pip install --upgrade pip - run: python -m pip install -e .[dev] - name: Set up pre-commit cache uses: actions/cache@v3 @@ -59,11 +62,45 @@ jobs: - run: python -Im pip install -e .[dev] - run: python -Ic 'import rdmo_plugins_statistics; print(rdmo_plugins_statistics.__version__)' + test: + name: "Test (${{ matrix.name }})" + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + include: + - name: RDMO 2.5 + rdmo: "rdmo>=2.5,<3" + - name: RDMO 3.0 release branch + rdmo: "rdmo @ git+https://github.com/rdmorganiser/rdmo.git@3.0.0/release" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + with: + python-version: "3.12" + cache: pip + - run: python -m pip install --upgrade pip + - name: Install RDMO target + run: python -m pip install "${{ matrix.rdmo }}" + - name: Install plugin + run: python -m pip install -e '.[dev]' + - name: Run tests + run: pytest + - name: Build wheel + run: python -m pip wheel --no-deps . --wheel-dir dist + - name: Verify wheel assets + run: | + unzip -l dist/*.whl | grep -q 'rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html' + unzip -l dist/*.whl | grep -q 'rdmo_plugins_statistics/locale/de/LC_MESSAGES/django.mo' + unzip -l dist/*.whl | grep -q 'rdmo_plugins_statistics/static/rdmo_plugins_statistics/css/statistics.css' + unzip -l dist/*.whl | grep -q 'rdmo_plugins_statistics/static/rdmo_plugins_statistics/js/chart.umd.min.js' + required-checks-pass: if: always() needs: - lint - dev-setup + - test runs-on: ubuntu-22.04 steps: - uses: re-actors/alls-green@release/v1 diff --git a/NOTICE b/NOTICE index 8646fcc..2cd231c 100644 --- a/NOTICE +++ b/NOTICE @@ -4,7 +4,7 @@ THIS NOTICE APPLIES TO ALL FILES CONTAINED IN THIS AND UNDERLYING FOLDERS RDMO - Research Data Management Organiser -Copyright (c) 2024 RDMO Arbeitsgemeinschaft +Copyright (c) 2015-2026 RDMO Community and individual contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,3 +14,8 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + +Third-party components retain their respective licenses. + +Chart.js is distributed under the MIT License. See: +rdmo_plugins_statistics/static/rdmo_plugins_statistics/js/chart.LICENSE.md diff --git a/README.md b/README.md index 3cf96d6..f1a8260 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # RDMO Statistics Plugin -The RDMO Statistics Plugin adds a statistics page to RDMO and displays project, user, catalog usage, and project progress data as bar charts. +The RDMO Statistics Plugin adds a statistics page to RDMO and displays project, user, catalog usage, and project progress data as charts. ## Features @@ -10,11 +10,14 @@ The statistics page currently provides: - Number of newly registered users over time - Cumulative total users - Catalog usage by number of projects -- Progress of individual projects +- Distribution of projects by ten-point progress groups +- Summary cards for projects and users - Daily, monthly, quarterly, and yearly aggregation for the time-based charts -- Optional start and end date filters +- Shared start and end date filters for all time-based charts - Totals for the displayed time range and the system's overall total where applicable -- Persistent interval and date-filter selection for time-based charts using browser storage +- Persistent and shareable interval and date-filter selection using browser storage and URL parameters +- Accessible data tables, semantic tooltips, and explicit empty states for every chart +- Complete catalog usage display - CSV export for every chart Access to the statistics page is restricted to site managers and controlled by the statistics.view_statistics permission. @@ -23,9 +26,11 @@ Project, catalog statistics and user registrations are restricted to the current ## Requirements -- RDMO 2.5.x or later +- RDMO 2.5.x +- RDMO 3.0 compatibility is tested against the RDMO 3.0 release branch until 3.0 is published. +- Python 3.10 or later, according to the supported RDMO version -Chart.js 4.5.1 is included with the plugin as a static asset. No npm installation or separate JavaScript build step is required. +Chart.js is included with the plugin as a static asset. No npm installation or separate JavaScript build step is required. ## Installation @@ -50,6 +55,7 @@ from django.urls import include, path urlpatterns += [ path('statistics/', include('rdmo_plugins_statistics.urls')), + path('api/v1/', include('rdmo_plugins_statistics.urls.v1')), ] ``` @@ -69,12 +75,11 @@ and add the following where the navigation entry should appear: ```django {% has_perm 'statistics.view_statistics' request.user as can_view_statistics %} +{% url 'statistics:index' as statistics_url %} -{% if can_view_statistics %} +{% if can_view_statistics and statistics_url %}
  • - - {% trans 'Statistics' %} - + {% trans 'Statistics' %}
  • {% endif %} ``` @@ -90,41 +95,42 @@ The default chart configuration is: ```python RDMO_STATISTICS = { 'projects': { - 'bar_color': '#7eafe0', + 'chart_color': '#7eafe0', 'empty_periods': True, 'label_orientation': 'auto', }, 'users': { - 'bar_color': '#65c5c4', + 'chart_color': '#65c5c4', 'empty_periods': True, 'label_orientation': 'auto', }, 'cumulative_users': { - 'bar_color': '#65c5c4', + 'chart_color': '#65c5c4', 'empty_periods': True, 'label_orientation': 'auto', }, 'catalogs': { - 'bar_color': '#a8d37d', - 'label_orientation': 'horizontal', + 'chart_color': '#a8d37d', + 'label_orientation': 'auto', 'orientation': 'horizontal', }, 'project_progress': { - 'bar_color': '#e6a15c', - 'label_orientation': 'horizontal', - 'orientation': 'horizontal', + 'chart_color': '#e6a15c', + 'label_orientation': 'auto', }, } ``` The chart configuration can be overridden in `rdmo-app/config/settings/local.py` using the optional `RDMO_STATISTICS` setting. Only the values that should differ from the defaults need to be specified. +All charts are displayed as bar charts. Project progress is always vertical. + Currently, the following configuration options are supported: -- `bar_color` +- `chart_color` - `empty_periods` (time-based charts): include periods without new records when set to `True` -- `label_orientation` -- `orientation` (category charts): display bars `horizontal` or `vertical` +- `label_orientation`: `auto`, `horizontal` or `vertical` (only effective on vertical bar charts) +- `orientation` (catalog usage only): display the chart `horizontal` or `vertical` ## Displayed Statistics @@ -157,22 +163,113 @@ Catalogs assigned to the current site are displayed together with the number of ### Project progress -Every project belonging to the current site is displayed with its interview progress as a rounded percentage. Projects whose interview has not started are shown with 0% progress. +The project-progress bar chart groups all projects belonging to the current site into fixed ten-point ranges from 0–9% through 90–99%, with a separate 100% group. The percentage groups are shown on the x-axis and project counts on the y-axis. Projects whose interview has not started are included in the 0–9% group. ## Frontend implementation -The Django view serializes the statistics with Django's `json_script` template filter. The bundled `statistics.js` reads that data and creates the charts with Chart.js 4.5.1. +Site-scoped queries and domain aggregates live in `rdmo_plugins_statistics.statistics`. The JSON API and server exports use these aggregates directly. The Django view passes them to `compute_dashboard_charts` in `rdmo_plugins_statistics.charts`, which adds chart settings, labels, and cumulative display values. + +Statistics can also be fetched separately in Python: + +```python +from rdmo_plugins_statistics.statistics import ( + fetch_catalog_statistics, + fetch_project_statistics, + fetch_statistics, + fetch_statistics_for_sites, + fetch_user_statistics, +) + +project_statistics = fetch_project_statistics(site) +user_statistics = fetch_user_statistics(site) +catalog_statistics = fetch_catalog_statistics(site) + +statistics = fetch_statistics(site) +site_statistics = fetch_statistics_for_sites(sites) +``` + +Single-site functions return domain aggregates. Projects expose `total`, `created` rows (`date`, `count`), and `progress` rows (`percentage`, `count`). Users expose `total` and `registered` rows (`date`, `count`). Catalogs expose `usage` rows (`id`, `uri`, `title`, `available`, `project_count`). + +`fetch_statistics(site)` combines these under `projects`, `users`, and `catalogs`. `fetch_statistics_for_sites(sites)` accepts an iterable or queryset of sites and returns ordered per-site results with a `site` dictionary containing `id`, `name`, and `domain`. Chart settings are accepted only by the chart adapter, not by fetchers. + +The aggregate and domain-specific JSON representations are available from: + +- `GET /api/v1/statistics/` +- `GET /api/v1/project-statistics/` +- `GET /api/v1/user-statistics/` +- `GET /api/v1/catalog-statistics/` + +All endpoints use the `statistics.view_statistics` permission and return data for the configured current site. They do not accept a site selector. Domain endpoints return the domain objects described above; the combined endpoint returns all three. Responses contain no chart colors, translated axis labels, or orientation. + +The Django template serializes chart rows with Django's `json_script` filter. The bundled `statistics.js` reads that data and creates the charts with Chart.js. + The frontend code: - Groups daily backend data into the selected interval -- Filters time-based data by start and end date +- Filters all time-based charts with one validated date range - Updates charts without reloading the page -- Stores the selected interval and date filters in `localStorage` +- Stores the selected interval and date filters in `localStorage` and the page URL - Draws values above vertical bars or beside horizontal bars -- Sorts category charts by value in descending order -- Sizes and scrolls category charts according to their orientation and number of entries -- Exports the currently displayed chart data as CSV +- Sizes and scrolls bar charts according to their orientation and number of entries +- Uses the currently displayed rows for charts, HTML tables, totals, and CSV exports + +## Migration from earlier versions + +The refactored statistics plugin replaces the previous row-level REST implementation with a permission-protected dashboard and aggregate APIs. + +The previous row-level response formats are no longer provided. The following paths now return domain aggregates: + +- `/api/v1/project-statistics/` +- `/api/v1/user-statistics/` + +The `/api/v1/statistics/` endpoint returns the combined domain aggregates, while `/api/v1/catalog-statistics/` provides catalog usage. This also intentionally replaces the intermediate `time_charts`/`category_charts` API contract. Consumers of that contract must migrate; only the HTML dashboard uses chart payloads. + +If an installation depends on one of the old endpoints, it should remain on the previous plugin version until that integration has been migrated. + +## Exporting statistics on a server + +Run the management command from the configured RDMO installation: + +```bash +python manage.py export_statistics --output-dir /srv/rdmo/statistics +python manage.py export_statistics --site-id 1 --site-id 2 --output-dir /srv/rdmo/statistics +python manage.py export_statistics --all-sites --output-dir /srv/rdmo/statistics +``` + +With no site option, only the current site is exported. Explicit site IDs are validated before writing; duplicate IDs are exported once. Dates are ISO dates grouped using Django's `TIME_ZONE`. All files are UTF-8 CSVs with `site_id,site_name,site_domain` as their first columns: + +| File | Remaining columns | +|---|---| +| `site_totals.csv` | `project_count,user_count` | +| `project_creation.csv` | `date,project_count` | +| `user_registration.csv` | `date,user_count` | +| `project_progress.csv` | `percentage,project_count` | +| `catalog_usage.csv` | `catalog_id,catalog_uri,catalog_title,available,project_count` | + +Empty tables retain their headers. Spreadsheet formula prefixes in text are escaped with a leading apostrophe; numeric values are not escaped. Catalog IDs and site IDs identify records within this RDMO database; cross-installation imports need a source identifier supplied by the pipeline. + +Each run replaces these five files with the latest state and leaves unrelated files alone. All files are prepared before replacement, but replacement is atomic only per file, not for the whole batch. Upload only after the command exits successfully, and avoid overlapping exports to the same directory. Fetches are sequential and are not a transactionally consistent snapshot during concurrent database updates. + +Example daily cron entry (adjust paths and supply your normal Django settings environment): + +```cron +0 2 * * * cd /srv/rdmo && .venv/bin/python manage.py export_statistics --all-sites --output-dir /srv/rdmo/statistics +``` + +These exports describe current records and site assignments. Creation/registration series exclude deleted records, and progress and catalog usage describe the current state. They do not reconstruct historical snapshots. CI upload and Metabase ingestion are managed outside this plugin. + +## Development + +Test setup and development fixture tools are documented in [`tests/README.md`](tests/README.md). + +Update the committed Chart.js browser build and license from a source checkout: + +```bash +python scripts/update_chartjs.py 4.5.1 +``` + +The updater downloads the exact npm release from jsDelivr and validates both files before replacing the existing assets. Review and commit the resulting diff. ## Uninstallation @@ -182,10 +279,11 @@ Remove the package: pip uninstall rdmo-plugins-statistics ``` -Then remove both plugin references from the RDMO configuration: +Then remove all plugin references from the RDMO configuration: 1. Remove `'rdmo_plugins_statistics'` from `INSTALLED_APPS`. 2. Remove `path('statistics/', include('rdmo_plugins_statistics.urls'))` from `urlpatterns`. -3. Remove the Statistics navigation entry from your theme override (`rdmo_theme/templates/core/base_navigation.html`). +3. Remove `path('api/v1/', include('rdmo_plugins_statistics.urls.v1'))` from `urlpatterns`. +4. Remove the Statistics navigation entry from your theme override (`rdmo_theme/templates/core/base_navigation.html`). All entries must be removed. Otherwise, Django will still try to import the uninstalled package and the application will not start. diff --git a/pyproject.toml b/pyproject.toml index 50f40ea..2a23f4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] -build-backend = "setuptools.build_meta" -requires = ["setuptools", "wheel"] +build-backend = "hatchling.build" +requires = ["hatchling", "hatch-vcs"] [project] name = "rdmo-plugins-statistics" @@ -17,34 +17,43 @@ license = {text = "Apache-2.0"} authors = [ {name = "RDMO Arbeitsgemeinschaft", email = "rdmo-team@listserv.dfn.de"}, ] -requires-python = ">=3.8" +requires-python = ">=3.10" classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", - "Framework :: Django :: 4.2", + "Framework :: Django :: 5.2", "Intended Audience :: Science/Research", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dynamic = [ "version", ] dependencies = [ - "rdmo" + "rdmo>=2.5,<4" ] [project.optional-dependencies] dev = [ + "model-bakery>=1.20,<2", "pre-commit", + "pytest>=8,<10", + "pytest-django>=4.8,<5", ] +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "tests.settings" +testpaths = ["tests"] +pythonpath = ["."] +addopts = "-ra" + [project.urls] documentation = "https://rdmo.readthedocs.io/en/latest/plugins/" homepage = "https://rdmorganiser.github.io" @@ -52,17 +61,15 @@ issues = "https://github.com/rdmorganiser/rdmo-plugins-statistics/issues" repository = "https://github.com/rdmorganiser/rdmo-plugins-statistics.git" slack = "https://rdmo.slack.com" -[tool.setuptools.packages.find] -include = ["rdmo_plugins_statistics*"] - -[tool.setuptools.package-data] -"*" = ["*"] +[tool.hatch.version] +source = "vcs" +fallback-version = "0.0.1" -[tool.setuptools.dynamic] -version = {attr = "rdmo_plugins_statistics.__version__"} +[tool.hatch.build.targets.wheel] +packages = ["rdmo_plugins_statistics"] [tool.ruff] -target-version = "py38" +target-version = "py310" line-length = 120 select = [ "B", # flake8-bugbear diff --git a/rdmo_plugins_statistics/__init__.py b/rdmo_plugins_statistics/__init__.py index f102a9c..7776efc 100644 --- a/rdmo_plugins_statistics/__init__.py +++ b/rdmo_plugins_statistics/__init__.py @@ -1 +1,7 @@ -__version__ = "0.0.1" +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _version + +try: + __version__ = _version(__package__) +except PackageNotFoundError: + __version__ = "0.0.0+unknown" diff --git a/rdmo_plugins_statistics/apps.py b/rdmo_plugins_statistics/apps.py index f636cfd..7cd5bda 100644 --- a/rdmo_plugins_statistics/apps.py +++ b/rdmo_plugins_statistics/apps.py @@ -1,19 +1,10 @@ from django.apps import AppConfig -from django.utils.translation import gettext_lazy as _ class StatisticsConfig(AppConfig): name = 'rdmo_plugins_statistics' - navigation_items = ( - { - 'name': 'statistics', - 'label': _('Statistics'), - 'url_name': 'statistics:index', - 'order': 100, - 'permission': 'statistics.view_statistics', - }, - ) + def ready(self): - from . import rules # noqa: F401 + from . import rules # noqa: F401 diff --git a/rdmo_plugins_statistics/charts.py b/rdmo_plugins_statistics/charts.py new file mode 100644 index 0000000..e5d9fc4 --- /dev/null +++ b/rdmo_plugins_statistics/charts.py @@ -0,0 +1,158 @@ +from django.conf import settings + +from .config import CATEGORY_CHART_DEFINITION, CATEGORY_CHART_SETTINGS, TIME_CHART_DEFINITION, TIME_CHART_SETTINGS + +TIME_CHART_CALCULATIONS = ( + 'period_count', + 'cumulative_count', +) + +def get_chart_settings(name, available_settings, custom_settings): + return {key: value for key, value in custom_settings.get(name, {}).items() if key in available_settings} + + +def compute_time_statistics(statistics, calculation): + if calculation not in TIME_CHART_CALCULATIONS: + raise ValueError(f'Unsupported time chart calculation: {calculation}') + + total = 0 + rows = [] + + for item in statistics: + period, count = item['date'], item['count'] + if calculation == 'cumulative_count': + total += count + value = total + else: + value = count + + rows.append({ + 'key': period, + 'label': period, + 'value': value, + }) + + return { + 'day': { + 'rows': rows, + }, + } + + +def compute_catalog_statistics(statistics): + rows = [ + { + 'key': item['id'], + 'label': item['title'], + 'value': item['project_count'], + 'label_suffix': ' *' if not item['available'] else '', + } + for item in statistics + ] + rows.sort(key=lambda row: (-row['value'], str(row['label']), row['key'])) + return {'rows': rows} + + +def compute_project_progress_statistics(statistics, group_size): + grouped_counts = dict.fromkeys((*range(0, 100, group_size), 100), 0) + + for item in statistics: + percentage = item['percentage'] + group = 100 if percentage == 100 else percentage // group_size * group_size + grouped_counts[group] += item['count'] + + return { + 'rows': [ + { + 'key': percentage, + 'label': '100%' if percentage == 100 else f'{percentage}-{percentage + group_size - 1}%', + 'value': grouped_counts[percentage], + } + for percentage in grouped_counts + ], + } + + +def compute_dashboard_summary(statistics): + projects = statistics['projects'] + + return { + 'projects': projects['total'], + 'users': statistics['users']['total'], + 'catalogs_in_use': sum( + item['project_count'] > 0 for item in statistics['catalogs']['usage'] + ), + 'complete_projects': next( + ( + item['count'] + for item in projects['progress'] + if item['percentage'] == 100 + ), + 0, + ), + } + + +def compute_time_chart(name, definition, statistics, total, custom_settings): + return { + **definition, + **get_chart_settings(name, TIME_CHART_SETTINGS, custom_settings), + 'statistics': compute_time_statistics(statistics, definition['calculation']), + 'total': total, + } + + +def compute_category_chart(name, definition, statistics, custom_settings): + chart_settings = get_chart_settings(name, CATEGORY_CHART_SETTINGS[name], custom_settings) + + chart = { + **definition, + **chart_settings, + 'statistics': statistics, + } + + if chart['chart_type'] != 'bar': + raise ValueError(f"Unsupported category chart type: {chart['chart_type']}") + + opposite_orientation = { + 'horizontal': 'vertical', + 'vertical': 'horizontal', + }[definition['orientation']] + + if chart_settings.get('orientation') == opposite_orientation: + chart['x_axis_title'], chart['y_axis_title'] = ( + chart['y_axis_title'], + chart['x_axis_title'], + ) + + return chart + + +def compute_dashboard_charts(statistics, custom_settings=None): + if custom_settings is None: + custom_settings = getattr(settings, 'RDMO_STATISTICS', {}) + projects, users = statistics['projects'], statistics['users'] + return { + 'summary': compute_dashboard_summary(statistics), + 'time_charts': [ + compute_time_chart(name, TIME_CHART_DEFINITION[name], rows, total, custom_settings) + for name, rows, total in ( + ('projects', projects['created'], projects['total']), + ('users', users['registered'], users['total']), + ('cumulative_users', users['registered'], users['total']), + ) + ], + 'category_charts': [ + compute_category_chart(name, CATEGORY_CHART_DEFINITION[name], rows, custom_settings) + for name, rows in ( + ('catalogs', compute_catalog_statistics(statistics['catalogs']['usage'])), + ( + 'project_progress', + compute_project_progress_statistics( + projects['progress'], + CATEGORY_CHART_DEFINITION['project_progress']['progress_group_size'], + ), + ), + ) + ], + } diff --git a/rdmo_plugins_statistics/config.py b/rdmo_plugins_statistics/config.py index dd0a04b..104720e 100644 --- a/rdmo_plugins_statistics/config.py +++ b/rdmo_plugins_statistics/config.py @@ -1,58 +1,61 @@ from django.utils.translation import gettext_lazy as _ -TIME_CHART_SETTINGS = { - 'bar_color', +TIME_CHART_SETTINGS = ( + 'chart_color', 'empty_periods', 'label_orientation', -} +) CATEGORY_CHART_SETTINGS = { - 'bar_color', - 'label_orientation', - 'orientation', + 'catalogs': ( + 'chart_color', + 'label_orientation', + 'orientation', + ), + 'project_progress': ( + 'chart_color', + 'label_orientation', + ), } TIME_CHART_DEFINITION = { 'projects': { 'key': 'project', 'type': 'time', - 'query_key': 'project', - 'date_field': 'created', 'calculation': 'period_count', - 'bar_color': '#7eafe0', + 'chart_color': '#7eafe0', 'dataset_label': _('Number of projects'), 'empty_periods': True, + 'empty_message': _('No projects were found for this period.'), 'label_orientation': 'auto', - 'title': _('Number of projects'), - 'x_axis_title': _('Created'), + 'title': _('Number of projects over time'), + 'x_axis_title': _('Date of creation'), 'y_axis_title': _('Number of projects'), }, 'users': { 'key': 'user', 'type': 'time', - 'query_key': 'user', - 'date_field': 'date_joined', 'calculation': 'period_count', - 'bar_color': '#65c5c4', + 'chart_color': '#65c5c4', 'dataset_label': _('Number of registered users'), 'empty_periods': True, + 'empty_message': _('No user registrations were found for this period.'), 'label_orientation': 'auto', - 'title': _('Number of registered users'), - 'x_axis_title': _('Registered'), + 'title': _('Number of registered users over time'), + 'x_axis_title': _('Date of registration'), 'y_axis_title': _('Number of registered users'), }, 'cumulative_users': { 'key': 'cumulative-user', 'type': 'time', - 'query_key': 'user', - 'date_field': 'date_joined', 'calculation': 'cumulative_count', - 'bar_color': '#65c5c4', + 'chart_color': '#65c5c4', 'dataset_label': _('Number of users'), 'empty_periods': True, + 'empty_message': _('No user data is available for this period.'), 'label_orientation': 'auto', 'title': _('Total users'), - 'x_axis_title': _('Date'), + 'x_axis_title': _('Date of registration'), 'y_axis_title': _('Number of users'), }, } @@ -61,10 +64,11 @@ 'catalogs': { 'key': 'catalog', 'type': 'category', - 'query_key': 'catalog', - 'bar_color': '#a8d37d', + 'chart_type': 'bar', + 'chart_color': '#a8d37d', 'dataset_label': _('Number of projects'), - 'label_orientation': 'horizontal', + 'empty_message': _('No catalog usage data is available.'), + 'label_orientation': 'auto', 'note': _('* unavailable'), 'orientation': 'horizontal', 'title': _('Catalog usage'), @@ -74,13 +78,15 @@ 'project_progress': { 'key': 'project-progress', 'type': 'category', - 'query_key': 'project-progress', - 'bar_color': '#e6a15c', - 'dataset_label': _('Progress (%)'), - 'label_orientation': 'horizontal', - 'orientation': 'horizontal', + 'chart_type': 'bar', + 'chart_color': '#e6a15c', + 'dataset_label': _('Number of projects'), + 'empty_message': _('No project progress data is available.'), + 'label_orientation': 'auto', + 'orientation': 'vertical', + 'progress_group_size': 10, 'title': _('Project progress'), 'x_axis_title': _('Progress (%)'), - 'y_axis_title': _('Project'), + 'y_axis_title': _('Number of projects'), }, } diff --git a/rdmo_plugins_statistics/exports.py b/rdmo_plugins_statistics/exports.py new file mode 100644 index 0000000..f2c66ea --- /dev/null +++ b/rdmo_plugins_statistics/exports.py @@ -0,0 +1,51 @@ +import csv +from pathlib import Path +from tempfile import TemporaryDirectory + +CSV_COLUMNS = { + 'site_totals': ('project_count', 'user_count'), + 'project_creation': ('date', 'project_count'), + 'user_registration': ('date', 'user_count'), + 'project_progress': ('percentage', 'project_count'), + 'catalog_usage': ('catalog_id', 'catalog_uri', 'catalog_title', 'available', 'project_count'), +} +SITE_COLUMNS = ('site_id', 'site_name', 'site_domain') + + +def escape_csv_cell(value): + if isinstance(value, str) and value.startswith(('=', '+', '-', '@', '\t', '\r', '\n')): + return "'" + value + return value + + +def export_statistics_csv(statistics, output_dir): + """Replace the five latest-state tables from already fetched per-site aggregates.""" + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + with TemporaryDirectory(prefix='.statistics-', dir=output_dir) as staging: + for table, columns in CSV_COLUMNS.items(): + with (Path(staging) / f'{table}.csv').open('w', encoding='utf-8', newline='') as stream: + writer = csv.writer(stream) + writer.writerow((*SITE_COLUMNS, *columns)) + for entry in statistics: + site = entry['site'] + prefix = (site['id'], site['name'], site['domain']) + if table == 'site_totals': + rows = [(entry['projects']['total'], entry['users']['total'])] + elif table == 'project_creation': + rows = ((row['date'], row['count']) for row in entry['projects']['created']) + elif table == 'user_registration': + rows = ((row['date'], row['count']) for row in entry['users']['registered']) + elif table == 'project_progress': + rows = ((row['percentage'], row['count']) for row in entry['projects']['progress']) + else: + rows = ( + (row['id'], row['uri'], row['title'], row['available'], row['project_count']) + for row in entry['catalogs']['usage'] + ) + for row in rows: + writer.writerow(escape_csv_cell(value) for value in (*prefix, *row)) + + # Replacement is atomic per file; consumers must wait for command success. + for table in CSV_COLUMNS: + (Path(staging) / f'{table}.csv').replace(output_dir / f'{table}.csv') diff --git a/rdmo_plugins_statistics/locale/de/LC_MESSAGES/django.mo b/rdmo_plugins_statistics/locale/de/LC_MESSAGES/django.mo index 937fecd..265d419 100644 Binary files a/rdmo_plugins_statistics/locale/de/LC_MESSAGES/django.mo and b/rdmo_plugins_statistics/locale/de/LC_MESSAGES/django.mo differ diff --git a/rdmo_plugins_statistics/locale/de/LC_MESSAGES/django.po b/rdmo_plugins_statistics/locale/de/LC_MESSAGES/django.po index 2cb8482..df93bc4 100644 --- a/rdmo_plugins_statistics/locale/de/LC_MESSAGES/django.po +++ b/rdmo_plugins_statistics/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-08-13 13:01+0200\n" +"POT-Creation-Date: 2026-09-18 10:55+0200\n" "PO-Revision-Date: 2026-08-13 13:04+0200\n" "Last-Translator: \n" "Language-Team: \n" @@ -19,63 +19,83 @@ msgstr "" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Generator: Poedit 3.9\n" -#: apps.py:11 -msgid "Statistics" -msgstr "Statistiken" - -#: config.py:23 config.py:26 config.py:28 config.py:66 config.py:71 +#: rdmo_plugins_statistics/config.py:27 rdmo_plugins_statistics/config.py:33 +#: rdmo_plugins_statistics/config.py:69 rdmo_plugins_statistics/config.py:75 +#: rdmo_plugins_statistics/config.py:83 rdmo_plugins_statistics/config.py:90 msgid "Number of projects" msgstr "Anzahl Projekte" -#: config.py:27 -msgid "Created" -msgstr "Erstellt" +#: rdmo_plugins_statistics/config.py:29 +msgid "No projects were found for this period." +msgstr "Für diesen Zeitraum wurden keine Projekte gefunden." + +#: rdmo_plugins_statistics/config.py:31 +#| msgid "Number of users over time" +msgid "Number of projects over time" +msgstr "Anzahl der Projekte im Zeitverlauf" + +#: rdmo_plugins_statistics/config.py:32 +msgid "Date of creation" +msgstr "Erstellungsdatum" -#: config.py:37 config.py:40 config.py:42 +#: rdmo_plugins_statistics/config.py:40 rdmo_plugins_statistics/config.py:46 msgid "Number of registered users" msgstr "Anzahl registrierter Nutzer" -#: config.py:41 -msgid "Registered" -msgstr "Registriert" +#: rdmo_plugins_statistics/config.py:42 +msgid "No user registrations were found for this period." +msgstr "Für diesen Zeitraum wurden keine Nutzerregistrierungen gefunden." + +#: rdmo_plugins_statistics/config.py:44 +#| msgid "Number of registered users" +msgid "Number of registered users over time" +msgstr "Anzahl der registrierten Nutzer im Zeitverlauf" + +#: rdmo_plugins_statistics/config.py:45 rdmo_plugins_statistics/config.py:58 +msgid "Date of registration" +msgstr "Registrierungsdatum" -#: config.py:51 config.py:56 +#: rdmo_plugins_statistics/config.py:53 rdmo_plugins_statistics/config.py:59 msgid "Number of users" msgstr "Anzahl Nutzer" -#: config.py:54 +#: rdmo_plugins_statistics/config.py:55 +msgid "No user data is available for this period." +msgstr "Für diesen Zeitraum sind keine Nutzerdaten verfügbar." + +#: rdmo_plugins_statistics/config.py:57 msgid "Total users" -msgstr "Summierte Nutzer" +msgstr "Gesamtzahl der Nutzer" -#: config.py:55 -msgid "Date" -msgstr "Datum" +#: rdmo_plugins_statistics/config.py:70 +msgid "No catalog usage data is available." +msgstr "Es sind keine Daten zur Katalognutzung verfügbar." -#: config.py:68 +#: rdmo_plugins_statistics/config.py:72 msgid "* unavailable" msgstr "* nicht verfügbar" -#: config.py:70 +#: rdmo_plugins_statistics/config.py:74 msgid "Catalog usage" msgstr "Katalognutzung" -#: config.py:72 +#: rdmo_plugins_statistics/config.py:76 msgid "Catalog" msgstr "Katalog" -#: config.py:79 config.py:83 -msgid "Progress (%)" -msgstr "Fortschritt (%)" +#: rdmo_plugins_statistics/config.py:84 +msgid "No project progress data is available." +msgstr "Es sind keine Daten zum Projektfortschritt verfügbar." -#: config.py:82 +#: rdmo_plugins_statistics/config.py:88 msgid "Project progress" msgstr "Projektfortschritt" -#: config.py:84 -msgid "Project" -msgstr "Projekt" +#: rdmo_plugins_statistics/config.py:89 +msgid "Progress (%)" +msgstr "Fortschritt (%)" -#: templates/rdmo_plugins_statistics/statistics.html:18 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:13 #, python-format msgid "" "\n" @@ -85,50 +105,112 @@ msgstr "" "\n" " Statistiken für %(site_name)s" -#: templates/rdmo_plugins_statistics/statistics.html:47 -#: templates/rdmo_plugins_statistics/statistics.html:145 -msgid "Export CSV" -msgstr "CSV-Export" +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:18 +msgid "Summary" +msgstr "Übersicht" + +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:20 +msgid "Projects" +msgstr "Projekte" -#: templates/rdmo_plugins_statistics/statistics.html:54 -msgid "Clear dates" -msgstr "Datumsfilter zurücksetzen" +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:24 +msgid "Users" +msgstr "Nutzer" -#: templates/rdmo_plugins_statistics/statistics.html:58 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:40 +msgid "The start date must not be after the end date." +msgstr "Das Startdatum darf nicht nach dem Enddatum liegen." + +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:42 msgid "From" msgstr "Von" -#: templates/rdmo_plugins_statistics/statistics.html:68 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:45 msgid "To" msgstr "Bis" -#: templates/rdmo_plugins_statistics/statistics.html:78 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:48 msgid "View by" msgstr "Anzeigen nach" -#: templates/rdmo_plugins_statistics/statistics.html:85 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:50 msgid "Day" msgstr "Tag" -#: templates/rdmo_plugins_statistics/statistics.html:86 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:51 msgid "Month" msgstr "Monat" -#: templates/rdmo_plugins_statistics/statistics.html:87 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:52 msgid "Quarter" msgstr "Quartal" -#: templates/rdmo_plugins_statistics/statistics.html:88 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:53 msgid "Year" msgstr "Jahr" -#: templates/rdmo_plugins_statistics/statistics.html:94 -msgid "Total in displayed period" -msgstr "Gesamt im angezeigten Zeitraum" +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:57 +msgid "Reset" +msgstr "Zurücksetzen" + +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:85 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:162 +msgid "Export CSV" +msgstr "CSV-Export" -#: templates/rdmo_plugins_statistics/statistics.html:100 -msgid "Total in system" -msgstr "Gesamt im System" +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:91 +msgid "Displayed" +msgstr "Angezeigt" -#~ msgid "Number of users over time" -#~ msgstr "Summierte Nutzer" +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:96 +msgid "Total" +msgstr "Gesamt" + +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:118 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:185 +msgid "Show data" +msgstr "Daten anzeigen" + +#~ msgid "Created" +#~ msgstr "Erstellt" + +#~ msgid "Registered" +#~ msgstr "Registriert" + +#~ msgid "Date" +#~ msgstr "Datum" + +#~ msgid "Total in displayed period" +#~ msgstr "Gesamt im angezeigten Zeitraum" + +#~ msgid "Total in system" +#~ msgstr "Gesamt im System" + +#~ msgid "Catalogs in use" +#~ msgstr "Verwendete Kataloge" + +#~ msgid "Complete projects" +#~ msgstr "Abgeschlossene Projekte" + +#~ msgid "Show" +#~ msgstr "Anzeigen" + +#~ msgid "Top 10" +#~ msgstr "Top 10" + +#~ msgid "Top 25" +#~ msgstr "Top 25" + +#~ msgid "All" +#~ msgstr "Alle" + +#~ msgid "Statistics" +#~ msgstr "Statistiken" + +#~ msgid "Clear dates" +#~ msgstr "Datumsfilter zurücksetzen" + +#, python-format +#~ msgid "Showing at most the %(limit)s most recently created projects." +#~ msgstr "" +#~ "Es werden höchstens die %(limit)s zuletzt erstellten Projekte angezeigt." diff --git a/rdmo_plugins_statistics/locale/es/LC_MESSAGES/django.mo b/rdmo_plugins_statistics/locale/es/LC_MESSAGES/django.mo index bf2c8c2..43add60 100644 Binary files a/rdmo_plugins_statistics/locale/es/LC_MESSAGES/django.mo and b/rdmo_plugins_statistics/locale/es/LC_MESSAGES/django.mo differ diff --git a/rdmo_plugins_statistics/locale/es/LC_MESSAGES/django.po b/rdmo_plugins_statistics/locale/es/LC_MESSAGES/django.po index b915b03..d5d32d8 100644 --- a/rdmo_plugins_statistics/locale/es/LC_MESSAGES/django.po +++ b/rdmo_plugins_statistics/locale/es/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-08-13 13:01+0200\n" +"POT-Creation-Date: 2026-09-18 10:55+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -19,67 +19,87 @@ msgstr "" "Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? " "1 : 2;\n" -#: apps.py:11 -msgid "Statistics" -msgstr "Estadísticas" - -#: config.py:23 config.py:26 config.py:28 config.py:66 config.py:71 +#: rdmo_plugins_statistics/config.py:27 rdmo_plugins_statistics/config.py:33 +#: rdmo_plugins_statistics/config.py:69 rdmo_plugins_statistics/config.py:75 +#: rdmo_plugins_statistics/config.py:83 rdmo_plugins_statistics/config.py:90 msgid "Number of projects" msgstr "Número de proyectos" -#: config.py:27 -msgid "Created" -msgstr "Creado" +#: rdmo_plugins_statistics/config.py:29 +msgid "No projects were found for this period." +msgstr "No se encontraron proyectos para este período." + +#: rdmo_plugins_statistics/config.py:31 +#| msgid "Number of projects" +msgid "Number of projects over time" +msgstr "Número de proyectos a lo largo del tiempo" + +#: rdmo_plugins_statistics/config.py:32 +msgid "Date of creation" +msgstr "Fecha de creación" -#: config.py:37 config.py:40 config.py:42 +#: rdmo_plugins_statistics/config.py:40 rdmo_plugins_statistics/config.py:46 msgid "Number of registered users" msgstr "Número de usuarios registrados" -#: config.py:41 -msgid "Registered" -msgstr "Registrado" +#: rdmo_plugins_statistics/config.py:42 +msgid "No user registrations were found for this period." +msgstr "No se encontraron registros de usuarios para este período." + +#: rdmo_plugins_statistics/config.py:44 +#| msgid "Number of registered users" +msgid "Number of registered users over time" +msgstr "Número de usuarios registrados a lo largo del tiempo" -#: config.py:51 config.py:56 +#: rdmo_plugins_statistics/config.py:45 rdmo_plugins_statistics/config.py:58 +msgid "Date of registration" +msgstr "Fecha de registro" + +#: rdmo_plugins_statistics/config.py:53 rdmo_plugins_statistics/config.py:59 #, fuzzy #| msgid "Number of registered users" msgid "Number of users" msgstr "Número de usuarios registrados" -#: config.py:54 +#: rdmo_plugins_statistics/config.py:55 +msgid "No user data is available for this period." +msgstr "No hay datos de usuarios disponibles para este período." + +#: rdmo_plugins_statistics/config.py:57 #, fuzzy #| msgid "Total in system" msgid "Total users" msgstr "Total en el sistema" -#: config.py:55 -msgid "Date" -msgstr "" +#: rdmo_plugins_statistics/config.py:70 +msgid "No catalog usage data is available." +msgstr "No hay datos disponibles sobre el uso de catálogos." -#: config.py:68 +#: rdmo_plugins_statistics/config.py:72 msgid "* unavailable" msgstr "* no disponible" -#: config.py:70 +#: rdmo_plugins_statistics/config.py:74 msgid "Catalog usage" msgstr "Uso de catálogos" -#: config.py:72 +#: rdmo_plugins_statistics/config.py:76 msgid "Catalog" msgstr "Catálogo" -#: config.py:79 config.py:83 -msgid "Progress (%)" -msgstr "" +#: rdmo_plugins_statistics/config.py:84 +msgid "No project progress data is available." +msgstr "No hay datos disponibles sobre el progreso de los proyectos." -#: config.py:82 +#: rdmo_plugins_statistics/config.py:88 msgid "Project progress" msgstr "" -#: config.py:84 -msgid "Project" +#: rdmo_plugins_statistics/config.py:89 +msgid "Progress (%)" msgstr "" -#: templates/rdmo_plugins_statistics/statistics.html:18 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:13 #, python-format msgid "" "\n" @@ -87,52 +107,109 @@ msgid "" " " msgstr "" -#: templates/rdmo_plugins_statistics/statistics.html:47 -#: templates/rdmo_plugins_statistics/statistics.html:145 -msgid "Export CSV" -msgstr "Exportar CSV" +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:18 +msgid "Summary" +msgstr "Resumen" + +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:20 +msgid "Projects" +msgstr "Proyectos" -#: templates/rdmo_plugins_statistics/statistics.html:54 -msgid "Clear dates" -msgstr "Restablecer filtro de fechas" +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:24 +msgid "Users" +msgstr "Usuarios" -#: templates/rdmo_plugins_statistics/statistics.html:58 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:40 +msgid "The start date must not be after the end date." +msgstr "La fecha de inicio no puede ser posterior a la fecha de fin." + +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:42 msgid "From" msgstr "Desde" -#: templates/rdmo_plugins_statistics/statistics.html:68 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:45 msgid "To" msgstr "Hasta" -#: templates/rdmo_plugins_statistics/statistics.html:78 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:48 msgid "View by" msgstr "Ver por" -#: templates/rdmo_plugins_statistics/statistics.html:85 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:50 msgid "Day" msgstr "Día" -#: templates/rdmo_plugins_statistics/statistics.html:86 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:51 msgid "Month" msgstr "Mes" -#: templates/rdmo_plugins_statistics/statistics.html:87 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:52 msgid "Quarter" msgstr "Trimestre" -#: templates/rdmo_plugins_statistics/statistics.html:88 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:53 msgid "Year" msgstr "Año" -#: templates/rdmo_plugins_statistics/statistics.html:94 -msgid "Total in displayed period" -msgstr "Total en el período mostrado" +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:57 +msgid "Reset" +msgstr "Restablecer" -#: templates/rdmo_plugins_statistics/statistics.html:100 -msgid "Total in system" -msgstr "Total en el sistema" +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:85 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:162 +msgid "Export CSV" +msgstr "Exportar CSV" -#, fuzzy -#~| msgid "Number of projects" -#~ msgid "Number of users over time" -#~ msgstr "Número de proyectos" +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:91 +msgid "Displayed" +msgstr "Mostrado" + +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:96 +msgid "Total" +msgstr "Total" + +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:118 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:185 +msgid "Show data" +msgstr "Mostrar datos" + +#~ msgid "Created" +#~ msgstr "Creado" + +#~ msgid "Registered" +#~ msgstr "Registrado" + +#~ msgid "Total in displayed period" +#~ msgstr "Total en el período mostrado" + +#~ msgid "Total in system" +#~ msgstr "Total en el sistema" + +#~ msgid "Catalogs in use" +#~ msgstr "Catálogos en uso" + +#~ msgid "Complete projects" +#~ msgstr "Proyectos completados" + +#~ msgid "Show" +#~ msgstr "Mostrar" + +#~ msgid "Top 10" +#~ msgstr "Top 10" + +#~ msgid "Top 25" +#~ msgstr "Top 25" + +#~ msgid "All" +#~ msgstr "Todos" + +#~ msgid "Statistics" +#~ msgstr "Estadísticas" + +#~ msgid "Clear dates" +#~ msgstr "Restablecer filtro de fechas" + +#, python-format +#~ msgid "Showing at most the %(limit)s most recently created projects." +#~ msgstr "" +#~ "Se muestran como máximo los %(limit)s proyectos creados más recientemente." diff --git a/rdmo_plugins_statistics/locale/fr/LC_MESSAGES/django.mo b/rdmo_plugins_statistics/locale/fr/LC_MESSAGES/django.mo index 86b4a30..afd14c2 100644 Binary files a/rdmo_plugins_statistics/locale/fr/LC_MESSAGES/django.mo and b/rdmo_plugins_statistics/locale/fr/LC_MESSAGES/django.mo differ diff --git a/rdmo_plugins_statistics/locale/fr/LC_MESSAGES/django.po b/rdmo_plugins_statistics/locale/fr/LC_MESSAGES/django.po index a20f567..626c29e 100644 --- a/rdmo_plugins_statistics/locale/fr/LC_MESSAGES/django.po +++ b/rdmo_plugins_statistics/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-08-13 13:01+0200\n" +"POT-Creation-Date: 2026-09-18 10:55+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,67 +18,87 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" -#: apps.py:11 -msgid "Statistics" -msgstr "Statistiques" - -#: config.py:23 config.py:26 config.py:28 config.py:66 config.py:71 +#: rdmo_plugins_statistics/config.py:27 rdmo_plugins_statistics/config.py:33 +#: rdmo_plugins_statistics/config.py:69 rdmo_plugins_statistics/config.py:75 +#: rdmo_plugins_statistics/config.py:83 rdmo_plugins_statistics/config.py:90 msgid "Number of projects" msgstr "Nombre de projets" -#: config.py:27 -msgid "Created" -msgstr "Créé" +#: rdmo_plugins_statistics/config.py:29 +msgid "No projects were found for this period." +msgstr "Aucun projet n’a été trouvé pour cette période." + +#: rdmo_plugins_statistics/config.py:31 +#| msgid "Number of projects" +msgid "Number of projects over time" +msgstr "Nombre de projets au fil du temps" + +#: rdmo_plugins_statistics/config.py:32 +msgid "Date of creation" +msgstr "Date de création" -#: config.py:37 config.py:40 config.py:42 +#: rdmo_plugins_statistics/config.py:40 rdmo_plugins_statistics/config.py:46 msgid "Number of registered users" msgstr "Nombre d'utilisateurs enregistrés" -#: config.py:41 -msgid "Registered" -msgstr "Enregistré" +#: rdmo_plugins_statistics/config.py:42 +msgid "No user registrations were found for this period." +msgstr "Aucune inscription d’utilisateur n’a été trouvée pour cette période." + +#: rdmo_plugins_statistics/config.py:44 +#| msgid "Number of registered users" +msgid "Number of registered users over time" +msgstr "Nombre d'utilisateurs enregistrés au fil du temps" -#: config.py:51 config.py:56 +#: rdmo_plugins_statistics/config.py:45 rdmo_plugins_statistics/config.py:58 +msgid "Date of registration" +msgstr "Date d'inscription" + +#: rdmo_plugins_statistics/config.py:53 rdmo_plugins_statistics/config.py:59 #, fuzzy #| msgid "Number of registered users" msgid "Number of users" msgstr "Nombre d'utilisateurs enregistrés" -#: config.py:54 +#: rdmo_plugins_statistics/config.py:55 +msgid "No user data is available for this period." +msgstr "Aucune donnée utilisateur n’est disponible pour cette période." + +#: rdmo_plugins_statistics/config.py:57 #, fuzzy #| msgid "Total in system" msgid "Total users" msgstr "Total du système" -#: config.py:55 -msgid "Date" -msgstr "" +#: rdmo_plugins_statistics/config.py:70 +msgid "No catalog usage data is available." +msgstr "Aucune donnée d’utilisation des catalogues n’est disponible." -#: config.py:68 +#: rdmo_plugins_statistics/config.py:72 msgid "* unavailable" msgstr "* indisponible" -#: config.py:70 +#: rdmo_plugins_statistics/config.py:74 msgid "Catalog usage" msgstr "Utilisation des catalogues" -#: config.py:72 +#: rdmo_plugins_statistics/config.py:76 msgid "Catalog" msgstr "Catalogue" -#: config.py:79 config.py:83 -msgid "Progress (%)" -msgstr "" +#: rdmo_plugins_statistics/config.py:84 +msgid "No project progress data is available." +msgstr "Aucune donnée d’avancement des projets n’est disponible." -#: config.py:82 +#: rdmo_plugins_statistics/config.py:88 msgid "Project progress" msgstr "" -#: config.py:84 -msgid "Project" +#: rdmo_plugins_statistics/config.py:89 +msgid "Progress (%)" msgstr "" -#: templates/rdmo_plugins_statistics/statistics.html:18 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:13 #, python-format msgid "" "\n" @@ -86,52 +106,108 @@ msgid "" " " msgstr "" -#: templates/rdmo_plugins_statistics/statistics.html:47 -#: templates/rdmo_plugins_statistics/statistics.html:145 -msgid "Export CSV" -msgstr "Exporter au format CSV" +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:18 +msgid "Summary" +msgstr "Résumé" + +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:20 +msgid "Projects" +msgstr "Projets" -#: templates/rdmo_plugins_statistics/statistics.html:54 -msgid "Clear dates" -msgstr "Réinitialiser le filtre de dates" +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:24 +msgid "Users" +msgstr "Utilisateurs" -#: templates/rdmo_plugins_statistics/statistics.html:58 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:40 +msgid "The start date must not be after the end date." +msgstr "La date de début ne doit pas être postérieure à la date de fin." + +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:42 msgid "From" msgstr "De" -#: templates/rdmo_plugins_statistics/statistics.html:68 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:45 msgid "To" msgstr "À" -#: templates/rdmo_plugins_statistics/statistics.html:78 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:48 msgid "View by" msgstr "Afficher par" -#: templates/rdmo_plugins_statistics/statistics.html:85 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:50 msgid "Day" msgstr "Jour" -#: templates/rdmo_plugins_statistics/statistics.html:86 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:51 msgid "Month" msgstr "Mois" -#: templates/rdmo_plugins_statistics/statistics.html:87 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:52 msgid "Quarter" msgstr "Trimestre" -#: templates/rdmo_plugins_statistics/statistics.html:88 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:53 msgid "Year" msgstr "Année" -#: templates/rdmo_plugins_statistics/statistics.html:94 -msgid "Total in displayed period" -msgstr "Total sur la période affichée" +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:57 +msgid "Reset" +msgstr "Réinitialiser" -#: templates/rdmo_plugins_statistics/statistics.html:100 -msgid "Total in system" -msgstr "Total du système" +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:85 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:162 +msgid "Export CSV" +msgstr "Exporter au format CSV" -#, fuzzy -#~| msgid "Number of projects" -#~ msgid "Number of users over time" -#~ msgstr "Nombre de projets" +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:91 +msgid "Displayed" +msgstr "Affiché" + +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:96 +msgid "Total" +msgstr "Total" + +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:118 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:185 +msgid "Show data" +msgstr "Afficher les données" + +#~ msgid "Created" +#~ msgstr "Créé" + +#~ msgid "Registered" +#~ msgstr "Enregistré" + +#~ msgid "Total in displayed period" +#~ msgstr "Total sur la période affichée" + +#~ msgid "Total in system" +#~ msgstr "Total du système" + +#~ msgid "Catalogs in use" +#~ msgstr "Catalogues utilisés" + +#~ msgid "Complete projects" +#~ msgstr "Projets terminés" + +#~ msgid "Show" +#~ msgstr "Afficher" + +#~ msgid "Top 10" +#~ msgstr "Top 10" + +#~ msgid "Top 25" +#~ msgstr "Top 25" + +#~ msgid "All" +#~ msgstr "Tous" + +#~ msgid "Statistics" +#~ msgstr "Statistiques" + +#~ msgid "Clear dates" +#~ msgstr "Réinitialiser le filtre de dates" + +#, python-format +#~ msgid "Showing at most the %(limit)s most recently created projects." +#~ msgstr "Affichage des %(limit)s projets créés les plus récents au maximum." diff --git a/rdmo_plugins_statistics/locale/it/LC_MESSAGES/django.mo b/rdmo_plugins_statistics/locale/it/LC_MESSAGES/django.mo index 766b9d0..b65cb3f 100644 Binary files a/rdmo_plugins_statistics/locale/it/LC_MESSAGES/django.mo and b/rdmo_plugins_statistics/locale/it/LC_MESSAGES/django.mo differ diff --git a/rdmo_plugins_statistics/locale/it/LC_MESSAGES/django.po b/rdmo_plugins_statistics/locale/it/LC_MESSAGES/django.po index 1af9f33..333f882 100644 --- a/rdmo_plugins_statistics/locale/it/LC_MESSAGES/django.po +++ b/rdmo_plugins_statistics/locale/it/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-08-13 13:01+0200\n" +"POT-Creation-Date: 2026-09-18 10:55+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,67 +18,87 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: apps.py:11 -msgid "Statistics" -msgstr "Statistiche" - -#: config.py:23 config.py:26 config.py:28 config.py:66 config.py:71 +#: rdmo_plugins_statistics/config.py:27 rdmo_plugins_statistics/config.py:33 +#: rdmo_plugins_statistics/config.py:69 rdmo_plugins_statistics/config.py:75 +#: rdmo_plugins_statistics/config.py:83 rdmo_plugins_statistics/config.py:90 msgid "Number of projects" msgstr "Numero di progetti" -#: config.py:27 -msgid "Created" -msgstr "Creato" +#: rdmo_plugins_statistics/config.py:29 +msgid "No projects were found for this period." +msgstr "Non sono stati trovati progetti per questo periodo." + +#: rdmo_plugins_statistics/config.py:31 +#| msgid "Number of projects" +msgid "Number of projects over time" +msgstr "Numero di progetti nel tempo" + +#: rdmo_plugins_statistics/config.py:32 +msgid "Date of creation" +msgstr "Data di creazione" -#: config.py:37 config.py:40 config.py:42 +#: rdmo_plugins_statistics/config.py:40 rdmo_plugins_statistics/config.py:46 msgid "Number of registered users" msgstr "Numero di utenti registrati" -#: config.py:41 -msgid "Registered" -msgstr "Registrato" +#: rdmo_plugins_statistics/config.py:42 +msgid "No user registrations were found for this period." +msgstr "Non sono state trovate registrazioni di utenti per questo periodo." + +#: rdmo_plugins_statistics/config.py:44 +#| msgid "Number of registered users" +msgid "Number of registered users over time" +msgstr "Numero di utenti registrati nel tempo" -#: config.py:51 config.py:56 +#: rdmo_plugins_statistics/config.py:45 rdmo_plugins_statistics/config.py:58 +msgid "Date of registration" +msgstr "Data di registrazione" + +#: rdmo_plugins_statistics/config.py:53 rdmo_plugins_statistics/config.py:59 #, fuzzy #| msgid "Number of registered users" msgid "Number of users" msgstr "Numero di utenti registrati" -#: config.py:54 +#: rdmo_plugins_statistics/config.py:55 +msgid "No user data is available for this period." +msgstr "Non sono disponibili dati sugli utenti per questo periodo." + +#: rdmo_plugins_statistics/config.py:57 #, fuzzy #| msgid "Total in system" msgid "Total users" msgstr "Totale nel sistema" -#: config.py:55 -msgid "Date" -msgstr "" +#: rdmo_plugins_statistics/config.py:70 +msgid "No catalog usage data is available." +msgstr "Non sono disponibili dati sull’utilizzo dei cataloghi." -#: config.py:68 +#: rdmo_plugins_statistics/config.py:72 msgid "* unavailable" msgstr "* non disponibile" -#: config.py:70 +#: rdmo_plugins_statistics/config.py:74 msgid "Catalog usage" msgstr "Utilizzo dei cataloghi" -#: config.py:72 +#: rdmo_plugins_statistics/config.py:76 msgid "Catalog" msgstr "Catalogo" -#: config.py:79 config.py:83 -msgid "Progress (%)" -msgstr "" +#: rdmo_plugins_statistics/config.py:84 +msgid "No project progress data is available." +msgstr "Non sono disponibili dati sull’avanzamento dei progetti." -#: config.py:82 +#: rdmo_plugins_statistics/config.py:88 msgid "Project progress" msgstr "" -#: config.py:84 -msgid "Project" +#: rdmo_plugins_statistics/config.py:89 +msgid "Progress (%)" msgstr "" -#: templates/rdmo_plugins_statistics/statistics.html:18 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:13 #, python-format msgid "" "\n" @@ -86,52 +106,110 @@ msgid "" " " msgstr "" -#: templates/rdmo_plugins_statistics/statistics.html:47 -#: templates/rdmo_plugins_statistics/statistics.html:145 -msgid "Export CSV" -msgstr "Esporta CSV" +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:18 +msgid "Summary" +msgstr "Riepilogo" + +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:20 +msgid "Projects" +msgstr "Progetti" -#: templates/rdmo_plugins_statistics/statistics.html:54 -msgid "Clear dates" -msgstr "Reimposta filtro date" +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:24 +msgid "Users" +msgstr "Utenti" -#: templates/rdmo_plugins_statistics/statistics.html:58 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:40 +msgid "The start date must not be after the end date." +msgstr "La data di inizio non può essere successiva alla data di fine." + +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:42 msgid "From" msgstr "Da" -#: templates/rdmo_plugins_statistics/statistics.html:68 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:45 msgid "To" msgstr "A" -#: templates/rdmo_plugins_statistics/statistics.html:78 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:48 msgid "View by" msgstr "Visualizza per" -#: templates/rdmo_plugins_statistics/statistics.html:85 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:50 msgid "Day" msgstr "Giorno" -#: templates/rdmo_plugins_statistics/statistics.html:86 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:51 msgid "Month" msgstr "Mese" -#: templates/rdmo_plugins_statistics/statistics.html:87 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:52 msgid "Quarter" msgstr "Trimestre" -#: templates/rdmo_plugins_statistics/statistics.html:88 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:53 msgid "Year" msgstr "Anno" -#: templates/rdmo_plugins_statistics/statistics.html:94 -msgid "Total in displayed period" -msgstr "Totale nel periodo visualizzato" +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:57 +msgid "Reset" +msgstr "Reimposta" -#: templates/rdmo_plugins_statistics/statistics.html:100 -msgid "Total in system" -msgstr "Totale nel sistema" +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:85 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:162 +msgid "Export CSV" +msgstr "Esporta CSV" -#, fuzzy -#~| msgid "Number of projects" -#~ msgid "Number of users over time" -#~ msgstr "Numero di progetti" +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:91 +msgid "Displayed" +msgstr "Visualizzato" + +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:96 +msgid "Total" +msgstr "Totale" + +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:118 +#: rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html:185 +msgid "Show data" +msgstr "Mostra dati" + +#~ msgid "Created" +#~ msgstr "Creato" + +#~ msgid "Registered" +#~ msgstr "Registrato" + +#~ msgid "Total in displayed period" +#~ msgstr "Totale nel periodo visualizzato" + +#~ msgid "Total in system" +#~ msgstr "Totale nel sistema" + +#~ msgid "Catalogs in use" +#~ msgstr "Cataloghi in uso" + +#~ msgid "Complete projects" +#~ msgstr "Progetti completati" + +#~ msgid "Show" +#~ msgstr "Mostra" + +#~ msgid "Top 10" +#~ msgstr "Primi 10" + +#~ msgid "Top 25" +#~ msgstr "Primi 25" + +#~ msgid "All" +#~ msgstr "Tutti" + +#~ msgid "Statistics" +#~ msgstr "Statistiche" + +#~ msgid "Clear dates" +#~ msgstr "Reimposta filtro date" + +#, python-format +#~ msgid "Showing at most the %(limit)s most recently created projects." +#~ msgstr "" +#~ "Vengono visualizzati al massimo i %(limit)s progetti creati più di " +#~ "recente." diff --git a/rdmo_plugins_statistics/management/__init__.py b/rdmo_plugins_statistics/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rdmo_plugins_statistics/management/commands/__init__.py b/rdmo_plugins_statistics/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rdmo_plugins_statistics/management/commands/export_statistics.py b/rdmo_plugins_statistics/management/commands/export_statistics.py new file mode 100644 index 0000000..7969777 --- /dev/null +++ b/rdmo_plugins_statistics/management/commands/export_statistics.py @@ -0,0 +1,38 @@ +from django.conf import settings +from django.contrib.sites.models import Site +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +from rdmo_plugins_statistics.exports import export_statistics_csv +from rdmo_plugins_statistics.statistics import fetch_statistics_for_sites + + +class Command(BaseCommand): + help = 'Export the latest per-site statistics into five CSV tables.' + + def add_arguments(self, parser): + selection = parser.add_mutually_exclusive_group() + selection.add_argument('--site-id', action='append', type=int) + selection.add_argument('--all-sites', action='store_true') + parser.add_argument('--output-dir', required=True) + + def handle(self, *args, **options): + if options['site_id']: + site_ids = list(dict.fromkeys(options['site_id'])) + sites_by_id = Site.objects.in_bulk(site_ids) + missing = set(site_ids) - sites_by_id.keys() + if missing: + raise CommandError(f'Unknown site IDs: {sorted(missing)}') + sites = [sites_by_id[site_id] for site_id in site_ids] + elif options['all_sites']: + sites = Site.objects.order_by('pk') + else: + sites = [Site.objects.get_current()] + + with timezone.override(settings.TIME_ZONE): + statistics = fetch_statistics_for_sites(sites) + try: + export_statistics_csv(statistics, options['output_dir']) + except OSError as exc: + raise CommandError(str(exc)) from exc + self.stdout.write(f'Exported statistics for {len(statistics)} sites to {options["output_dir"]}.') diff --git a/rdmo_plugins_statistics/serializers.py b/rdmo_plugins_statistics/serializers.py new file mode 100644 index 0000000..b2e8704 --- /dev/null +++ b/rdmo_plugins_statistics/serializers.py @@ -0,0 +1,22 @@ +from rest_framework import serializers + + +class ProjectStatisticsSerializer(serializers.Serializer): + total = serializers.IntegerField() + created = serializers.ListField(child=serializers.DictField()) + progress = serializers.ListField(child=serializers.DictField()) + + +class UserStatisticsSerializer(serializers.Serializer): + total = serializers.IntegerField() + registered = serializers.ListField(child=serializers.DictField()) + + +class CatalogStatisticsSerializer(serializers.Serializer): + usage = serializers.ListField(child=serializers.DictField()) + + +class StatisticsSerializer(serializers.Serializer): + projects = ProjectStatisticsSerializer() + users = UserStatisticsSerializer() + catalogs = CatalogStatisticsSerializer() diff --git a/rdmo_plugins_statistics/static/rdmo_plugins_statistics/css/statistics.css b/rdmo_plugins_statistics/static/rdmo_plugins_statistics/css/statistics.css index 987ae95..4509f87 100644 --- a/rdmo_plugins_statistics/static/rdmo_plugins_statistics/css/statistics.css +++ b/rdmo_plugins_statistics/static/rdmo_plugins_statistics/css/statistics.css @@ -11,6 +11,32 @@ font-weight: 600; } +.statistics-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.statistics-summary-card { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 1rem 1.25rem; + border: 1px solid #ddd; + background-color: #fff; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); +} + +.statistics-summary-card span { + color: #666; + font-size: 0.9rem; +} + +.statistics-summary-card strong { + font-size: 1.75rem; +} + .statistics-section { margin-bottom: 3rem; } @@ -36,31 +62,51 @@ .statistics-chart-controls { display: flex; + flex-wrap: wrap; justify-content: center; align-items: center; gap: 0.75rem; margin-bottom: 1rem; } +.statistics-time-filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + margin-bottom: 2rem; +} + +.statistics-time-filters label, .statistics-chart-controls label { font-size: 0.9rem; font-weight: 600; } +.statistics-time-filters select, .statistics-chart-controls select { min-width: 90px; padding: 0.3rem 0.6rem; - border: 0; border-radius: 3px; - background-color: #008f83; color: #fff; - font: inherit; cursor: pointer; } +.statistics-date-error { + flex-basis: 100%; + margin: 0; + color: #b02a37; +} + +.statistics-empty-message { + margin: 1rem 0 0; + color: #666; + text-align: center; +} + .statistics-chart-container { position: relative; height: 340px; @@ -85,7 +131,7 @@ .statistics-chart-y-axis-title, .statistics-chart-x-axis-title { color: #666; - font-size: 0.75rem; + font-size: 1rem; text-align: center; } @@ -111,6 +157,25 @@ .project-statistics-total { display: flex; + flex-wrap: wrap; gap: 1rem; white-space: nowrap; } + +.statistics-data-table { + margin-top: 1.5rem; +} + +.statistics-data-table summary { + cursor: pointer; + font-weight: 600; +} + +.statistics-table-viewport { + margin-top: 0.75rem; + overflow-x: auto; +} + +.statistics-data-table table { + margin-bottom: 0; +} diff --git a/rdmo_plugins_statistics/static/rdmo_plugins_statistics/js/chart.LICENSE.md b/rdmo_plugins_statistics/static/rdmo_plugins_statistics/js/chart.LICENSE.md new file mode 100644 index 0000000..f216610 --- /dev/null +++ b/rdmo_plugins_statistics/static/rdmo_plugins_statistics/js/chart.LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright (c) 2014-2024 Chart.js Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/rdmo_plugins_statistics/static/rdmo_plugins_statistics/js/chart-4.5.1.umd.min.js b/rdmo_plugins_statistics/static/rdmo_plugins_statistics/js/chart.umd.min.js similarity index 100% rename from rdmo_plugins_statistics/static/rdmo_plugins_statistics/js/chart-4.5.1.umd.min.js rename to rdmo_plugins_statistics/static/rdmo_plugins_statistics/js/chart.umd.min.js diff --git a/rdmo_plugins_statistics/static/rdmo_plugins_statistics/js/statistics.js b/rdmo_plugins_statistics/static/rdmo_plugins_statistics/js/statistics.js index 1d2b142..3262851 100644 --- a/rdmo_plugins_statistics/static/rdmo_plugins_statistics/js/statistics.js +++ b/rdmo_plugins_statistics/static/rdmo_plugins_statistics/js/statistics.js @@ -1,5 +1,13 @@ const CUMULATIVE_CALCULATION = 'cumulative_count' +const TIME_INTERVALS = ['day', 'month', 'quarter', 'year'] +const DEFAULT_TIME_INTERVAL = 'month' +const TIME_STORAGE_KEYS = { + interval: 'rdmo-statistics-interval', + start: 'rdmo-statistics-start', + end: 'rdmo-statistics-end' +} + const TIME_PERIOD_LIMITS = { day: 31, month: 24, @@ -10,10 +18,10 @@ const TIME_PERIOD_LIMITS = { const CATEGORY_CHART_MIN_SIZE = 340 const CATEGORY_CHART_AXIS_SIZE = 80 const HORIZONTAL_CATEGORY_SIZE = 32 -const VERTICAL_CATEGORY_SIZE = 64 +const VERTICAL_CATEGORY_SIZE = 40 const updateClearDatesButton = (button, filters) => { - button.disabled = !filters.start && !filters.end + button.disabled = !filters.start && !filters.end && filters.interval === DEFAULT_TIME_INTERVAL } const getDateDisplayLabel = (row, interval) => { @@ -271,6 +279,16 @@ const statisticsTypes = { time: { getRows: getTimeChartRows, + hasData: (statistics, filters, container, rows) => { + if (container.dataset.calculation === CUMULATIVE_CALCULATION) { + return rows.some((row) => row.value > 0) + } + + return statistics.day.rows.some((row) => ( + isDateInRange(row.key.slice(0, 10), filters) + )) + }, + getDisplayLabel: (row, filters) => { return getDateDisplayLabel(row, filters.interval) }, @@ -283,9 +301,9 @@ const statisticsTypes = { }, category: { - getRows: (statistics) => { - return [...statistics.rows].sort((a, b) => b.value - a.value) - }, + getRows: (statistics) => [...statistics.rows], + + hasData: (_statistics, _filters, _container, rows) => rows.some((row) => row.value > 0), getDisplayLabel: (row) => { return `${row.label}${row.label_suffix || ''}` @@ -304,13 +322,16 @@ const prepareChartData = (statisticsType, statistics, filters, container) => { return { rows, displayLabels: rows.map((row) => statisticsType.getDisplayLabel(row, filters)), - tickRotation: getLabelTickRotation(container, defaultTickRotation) + tickRotation: getLabelTickRotation(container, defaultTickRotation), + hasData: statisticsType.hasData(statistics, filters, container, rows) } } const updateBarChart = (chart, preparedData) => { chart.data.labels = preparedData.displayLabels chart.data.datasets[0].data = preparedData.rows.map((row) => row.value) + chart.data.datasets[0].statisticsRows = preparedData.rows + chart.data.datasets[0].statisticsLabels = preparedData.displayLabels chart.options.scales.x.ticks.minRotation = preparedData.tickRotation.min chart.options.scales.x.ticks.maxRotation = preparedData.tickRotation.max @@ -378,10 +399,12 @@ const drawValueLabelsPlugin = { } } -const getChartDataset = (container, preparedData) => { +const getBarChartDataset = (container, preparedData) => { return { label: container.dataset.datasetLabel, data: preparedData.rows.map((row) => row.value), + statisticsRows: preparedData.rows, + statisticsLabels: preparedData.displayLabels, backgroundColor: container.dataset.barColor, borderWidth: 0, barPercentage: 0.85, @@ -389,6 +412,27 @@ const getChartDataset = (container, preparedData) => { } } +const getTooltipCallbacks = (container) => { + return { + title(items) { + if (items.length === 0) { + return '' + } + + const item = items[0] + const label = item.dataset.statisticsLabels[item.dataIndex] + + return `${container.dataset.rowAxisTitle}: ${label}` + }, + + label(item) { + const row = item.dataset.statisticsRows[item.dataIndex] + + return `${container.dataset.datasetLabel}: ${row.value}` + } + } +} + const getChartScales = (container, preparedData, isHorizontal) => { return { x: { @@ -447,7 +491,8 @@ const getChartOptions = (container, preparedData, isHorizontal) => { }, tooltip: { - displayColors: false + displayColors: false, + callbacks: getTooltipCallbacks(container) } }, @@ -464,7 +509,7 @@ const createBarChart = (chartElement, container, preparedData) => { data: { labels: preparedData.displayLabels, datasets: [ - getChartDataset(container, preparedData) + getBarChartDataset(container, preparedData) ] }, @@ -476,87 +521,178 @@ const createBarChart = (chartElement, container, preparedData) => { }) } -const getTimeChartControls = (container) => { +const createChart = createBarChart + +const isValidDateValue = (value) => { + if (!value) { + return false + } + + const input = document.createElement('input') + input.type = 'date' + input.value = value + + return input.value === value +} + +const getInitialFilterValue = (parameters, name, storageKey, validate, fallback) => { + const parameter = parameters.get(name) + + if (validate(parameter)) { + return parameter + } + + const stored = localStorage.getItem(storageKey) + + return validate(stored) ? stored : fallback +} + +const clearLegacyTimeFilters = () => { + ['project', 'user', 'cumulative-user'].forEach((name) => { + const key = `${name}-statistics-interval` + localStorage.removeItem(key) + localStorage.removeItem(`${key}-start`) + localStorage.removeItem(`${key}-end`) + }) +} + +const createTimeFilterControls = () => { + const container = document.querySelector('[data-statistics-time-controls]') + + if (!container) { + return null + } + const intervalSelect = container.querySelector('.statistics-interval') const startDateInput = container.querySelector('.statistics-start-date') const endDateInput = container.querySelector('.statistics-end-date') const clearDatesButton = container.querySelector('.statistics-clear-dates') - const storageKey = container.dataset.storageKey - const storedInterval = localStorage.getItem(storageKey) - const storedStart = localStorage.getItem(`${storageKey}-start`) - const storedEnd = localStorage.getItem(`${storageKey}-end`) - - if (storedInterval) { - intervalSelect.value = storedInterval + const errorElement = container.querySelector('.statistics-date-error') + const parameters = new URLSearchParams(window.location.search) + const filters = { + interval: getInitialFilterValue( + parameters, + 'interval', + TIME_STORAGE_KEYS.interval, + (value) => TIME_INTERVALS.includes(value), + DEFAULT_TIME_INTERVAL, + ), + start: getInitialFilterValue( + parameters, + 'from', + TIME_STORAGE_KEYS.start, + isValidDateValue, + '', + ), + end: getInitialFilterValue( + parameters, + 'to', + TIME_STORAGE_KEYS.end, + isValidDateValue, + '', + ) } + const listeners = [] + let valid = true - if (storedStart) { - startDateInput.value = storedStart - } + intervalSelect.value = filters.interval + startDateInput.value = filters.start + endDateInput.value = filters.end + + const updateValidity = () => { + startDateInput.max = filters.end + endDateInput.min = filters.start + valid = !(filters.start && filters.end && filters.start > filters.end) - if (storedEnd) { - endDateInput.value = storedEnd + startDateInput.setAttribute('aria-invalid', String(!valid)) + endDateInput.setAttribute('aria-invalid', String(!valid)) + errorElement.textContent = valid ? '' : container.dataset.invalidDateMessage + errorElement.hidden = valid + + return valid } - const filters = { - interval: intervalSelect.value, - start: startDateInput.value, - end: endDateInput.value + const persist = () => { + localStorage.setItem(TIME_STORAGE_KEYS.interval, filters.interval) + + for (const name of ['start', 'end']) { + if (filters[name]) { + localStorage.setItem(TIME_STORAGE_KEYS[name], filters[name]) + } else { + localStorage.removeItem(TIME_STORAGE_KEYS[name]) + } + } } - updateClearDatesButton(clearDatesButton, filters) + const updateUrl = () => { + const url = new URL(window.location.href) + url.searchParams.set('interval', filters.interval) - return { - intervalSelect, - startDateInput, - endDateInput, - clearDatesButton, - storageKey, - filters + for (const [name, value] of [['from', filters.start], ['to', filters.end]]) { + if (value) { + url.searchParams.set(name, value) + } else { + url.searchParams.delete(name) + } + } + + window.history.replaceState({}, '', url) } -} -const addTimeChartListeners = (controls, updateChart) => { - const { - intervalSelect, - startDateInput, - endDateInput, - clearDatesButton, - storageKey, - filters - } = controls + const notify = () => { + updateValidity() + updateClearDatesButton(clearDatesButton, filters) + persist() + updateUrl() + listeners.forEach((listener) => listener()) + } intervalSelect.addEventListener('change', () => { filters.interval = intervalSelect.value - localStorage.setItem(storageKey, filters.interval) - updateChart() + notify() }) startDateInput.addEventListener('change', () => { filters.start = startDateInput.value - localStorage.setItem(`${storageKey}-start`, filters.start) - updateClearDatesButton(clearDatesButton, filters) - updateChart() + notify() }) endDateInput.addEventListener('change', () => { filters.end = endDateInput.value - localStorage.setItem(`${storageKey}-end`, filters.end) - updateClearDatesButton(clearDatesButton, filters) - updateChart() + notify() }) clearDatesButton.addEventListener('click', () => { + intervalSelect.value = DEFAULT_TIME_INTERVAL startDateInput.value = '' endDateInput.value = '' + filters.interval = DEFAULT_TIME_INTERVAL filters.start = '' filters.end = '' - - localStorage.removeItem(`${storageKey}-start`) - localStorage.removeItem(`${storageKey}-end`) - updateClearDatesButton(clearDatesButton, filters) - updateChart() + notify() }) + + updateValidity() + updateClearDatesButton(clearDatesButton, filters) + persist() + updateUrl() + clearLegacyTimeFilters() + + return { + filters, + isValid: () => valid, + subscribe: (listener) => listeners.push(listener) + } +} + +const escapeCsvCell = (value) => { + let text = String(value) + + if (typeof value === 'string' && /^[=+\-@\t\r]/.test(text)) { + text = `'${text}` + } + + return `"${text.replaceAll('"', '""')}"` } const downloadCsv = (container, filters, preparedData) => { @@ -580,11 +716,7 @@ const downloadCsv = (container, filters, preparedData) => { ] const csv = csvRows - .map((row) => ( - row - .map((value) => `"${String(value).replaceAll('"', '""')}"`) - .join(',') - )) + .map((row) => row.map(escapeCsvCell).join(',')) .join('\n') const name = container.dataset.statisticsId @@ -612,11 +744,32 @@ const downloadCsv = (container, filters, preparedData) => { URL.revokeObjectURL(url) } -const createStatisticsChart = (container) => { +const renderDataTable = (tableBody, preparedData) => { + const rows = preparedData.rows.map((row, index) => { + const tableRow = document.createElement('tr') + const labelCell = document.createElement('th') + const valueCell = document.createElement('td') + + labelCell.scope = 'row' + labelCell.textContent = preparedData.displayLabels[index] + valueCell.textContent = row.value + tableRow.append(labelCell, valueCell) + + return tableRow + }) + + tableBody.replaceChildren(...rows) +} + +const createStatisticsChart = (container, timeControls) => { const statisticsElement = document.getElementById(container.dataset.statisticsId) const chartElement = container.querySelector('.statistics-chart') + const chartLayout = container.querySelector('.statistics-chart-layout') const totalElement = container.querySelector('.statistics-total') const exportButton = container.querySelector('.statistics-export-csv') + const emptyElement = container.querySelector('.statistics-empty-message') + const dataTable = container.querySelector('.statistics-data-table') + const tableBody = dataTable.querySelector('tbody') const statistics = JSON.parse(statisticsElement.textContent) const statisticsTypeName = container.dataset.statisticsType @@ -627,11 +780,7 @@ const createStatisticsChart = (container) => { return } - const controls = statisticsTypeName === 'time' - ? getTimeChartControls(container) - : null - - const filters = controls?.filters || {} + const filters = statisticsTypeName === 'time' ? timeControls.filters : {} const getPreparedData = () => { return prepareChartData(statisticsType, statistics, filters, container) @@ -644,25 +793,49 @@ const createStatisticsChart = (container) => { } const initialData = getPreparedData() + const chart = createChart(chartElement, container, initialData) - updateTotal(totalElement, initialData.rows) - - if (statisticsTypeName === 'category') { - setCategoryChartSize(container, initialData.rows) - } + const render = () => { + const isInvalid = statisticsTypeName === 'time' && !timeControls.isValid() - const chart = createBarChart(chartElement, container, initialData) + if (isInvalid) { + chartLayout.hidden = true + dataTable.hidden = true + emptyElement.hidden = true + exportButton.disabled = true + return + } - const updateChart = () => { const preparedData = getPreparedData() - updateBarChart(chart, preparedData) + chartLayout.hidden = !preparedData.hasData + dataTable.hidden = !preparedData.hasData + emptyElement.textContent = preparedData.hasData ? '' : container.dataset.emptyMessage + emptyElement.hidden = preparedData.hasData + exportButton.disabled = !preparedData.hasData + + if (preparedData.hasData) { + updateBarChart(chart, preparedData) + } + updateTotal(totalElement, preparedData.rows) + renderDataTable(tableBody, preparedData) + + if (statisticsTypeName === 'category' && container.dataset.chartType === 'bar') { + setCategoryChartSize(container, preparedData.rows) + } + } - if (controls) { - addTimeChartListeners(controls, updateChart) + if (statisticsTypeName === 'time') { + timeControls.subscribe(render) } + + render() } -document.querySelectorAll('[data-statistics-chart]').forEach(createStatisticsChart) +const timeControls = createTimeFilterControls() + +document.querySelectorAll('[data-statistics-chart]').forEach((container) => { + createStatisticsChart(container, timeControls) +}) diff --git a/rdmo_plugins_statistics/statistics.py b/rdmo_plugins_statistics/statistics.py new file mode 100644 index 0000000..d7992ff --- /dev/null +++ b/rdmo_plugins_statistics/statistics.py @@ -0,0 +1,84 @@ +from collections import Counter + +from django.contrib.auth import get_user_model +from django.db.models import Count, Q +from django.db.models.functions import TruncDate + +from rdmo.projects.models import Project +from rdmo.questions.models import Catalog + + +def annotate_and_fetch_date_counts(queryset, date_field, *, distinct=False): + return [ + {'date': period.isoformat(), 'count': count} + for period, count in ( + queryset.annotate(period=TruncDate(date_field)).values('period') + .annotate(count=Count('id', distinct=distinct)) + .values_list('period', 'count').order_by('period') + ) + ] + + +def fetch_project_statistics(site): + projects = Project.objects.filter(site=site) + created = annotate_and_fetch_date_counts(projects, 'created') + progress = Counter() + for count, total, project_count in ( + projects.order_by().values('progress_count', 'progress_total') + .annotate(project_count=Count('id')) + .values_list('progress_count', 'progress_total', 'project_count') + ): + percentage = round(100 * count / total) if count is not None and total else 0 + progress[percentage] += project_count + return { + 'total': sum(row['count'] for row in created), + 'created': created, + 'progress': [ + {'percentage': percentage, 'count': progress[percentage]} + for percentage in sorted(progress) + ], + } + + +def fetch_user_statistics(site): + users = get_user_model().objects.filter(role__member=site) + date_joined = annotate_and_fetch_date_counts(users, 'date_joined', distinct=True) + return { + 'total': sum(row['count'] for row in date_joined), + 'registered': date_joined + } + + +def fetch_catalog_statistics(site): + catalogs = ( + Catalog.objects.filter(sites=site) + .annotate(project_count=Count('projects', filter=Q(projects__site=site), distinct=True)) + .order_by('id') + ) + return { + 'usage': [ + { + 'id': catalog.pk, + 'uri': catalog.uri, + 'title': catalog.title, + 'available': catalog.available, + 'project_count': catalog.project_count, + } + for catalog in catalogs + ], + } + + +def fetch_statistics(site): + return { + 'projects': fetch_project_statistics(site), + 'users': fetch_user_statistics(site), + 'catalogs': fetch_catalog_statistics(site), + } + + +def fetch_statistics_for_sites(sites): + return [ + {'site': {'id': site.pk, 'name': site.name, 'domain': site.domain}, **fetch_statistics(site)} + for site in sites + ] diff --git a/rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html b/rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html index c444d24..2ecb2dc 100644 --- a/rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html +++ b/rdmo_plugins_statistics/templates/rdmo_plugins_statistics/statistics.html @@ -4,173 +4,209 @@ {% block css %} {{ block.super }} - + {% endblock %} {% block content %} -
    -

    {% blocktranslate with site_name=current_site.name %} Statistics for {{ site_name }} {% endblocktranslate %}

    +
    +
    + {% translate "Projects" %} + {{ summary.projects }} +
    +
    + {% translate "Users" %} + {{ summary.users }} +
    +{#
    #} +{# {% translate "Catalogs in use" %}#} +{# {{ summary.catalogs_in_use }}#} +{#
    #} +{#
    #} +{# {% translate "Complete projects" %}#} +{# {{ summary.complete_projects }}#} +{#
    #} +
    + +
    + + + + + + + + + + + + +
    + {% for chart in time_charts %} - {% with statistics_id=chart.key|add:"-statistics-data" storage_key=chart.key|add:"-statistics-interval" %} + {% with statistics_id=chart.key|add:"-statistics-data" table_id=chart.key|add:"-statistics-table" %}

    {{ chart.title }}

    - - - - - - - - - - - - - - -
    {% if chart.calculation != 'cumulative_count' %} - {% translate "Total in displayed period" %}: + {% translate "Displayed" %}: {% endif %} - - {% translate "Total in system" %}: + {% translate "Total" %}: {{ chart.total }}
    -
    -
    - {{ chart.y_axis_title }} -
    + +
    +
    {{ chart.y_axis_title }}
    - +
    +
    {{ chart.x_axis_title }}
    +
    -
    - {{ chart.x_axis_title }} +
    + {% translate "Show data" %} +
    + + + + + + + + +
    {{ chart.x_axis_title }}{{ chart.dataset_label }}
    -
    +
    {% endwith %} {% endfor %} {% for chart in category_charts %} - {% with statistics_id=chart.key|add:"-statistics-data" %} -
    -

    {{ chart.title }}

    - -
    - -
    -
    - {{ chart.y_axis_title }} -
    +
    -
    -
    - -
    -
    + -
    - {{ chart.x_axis_title }} +
    +
    {{ chart.y_axis_title }}
    +
    +
    +
    - - {% if chart.note %} -

    - {{ chart.note }} -

    - {% endif %} +
    {{ chart.x_axis_title }}
    -
    - {% endwith %} - {% endfor %} +
    + {% translate "Show data" %} +
    + + + + {% if chart.orientation == 'horizontal' %} + + {% else %} + + {% endif %} + + + + +
    {{ chart.y_axis_title }}{{ chart.x_axis_title }}{{ chart.dataset_label }}
    +
    +
    + + {% if chart.note %} +

    {{ chart.note }}

    + {% endif %} +
    + + {% endwith %} + {% endfor %} {% for chart in time_charts %} @@ -187,12 +223,6 @@

    {{ chart.title }}

    {% block js %} {{ block.super }} - - + + {% endblock %} diff --git a/rdmo_plugins_statistics/urls/v1.py b/rdmo_plugins_statistics/urls/v1.py new file mode 100644 index 0000000..caa2e25 --- /dev/null +++ b/rdmo_plugins_statistics/urls/v1.py @@ -0,0 +1,22 @@ +from django.urls import include, path + +from rest_framework import routers + +from ..viewsets import ( + CatalogStatisticsViewSet, + ProjectStatisticsViewSet, + StatisticsViewSet, + UserStatisticsViewSet, +) + +app_name = 'v1-statistics' + +router = routers.DefaultRouter() +router.register(r'statistics', StatisticsViewSet, basename='statistics') +router.register(r'project-statistics', ProjectStatisticsViewSet, basename='project-statistics') +router.register(r'user-statistics', UserStatisticsViewSet, basename='user-statistics') +router.register(r'catalog-statistics', CatalogStatisticsViewSet, basename='catalog-statistics') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/rdmo_plugins_statistics/utils.py b/rdmo_plugins_statistics/utils.py deleted file mode 100644 index 18f8ecb..0000000 --- a/rdmo_plugins_statistics/utils.py +++ /dev/null @@ -1,28 +0,0 @@ -def get_time_statistics(statistics, calculation): - total = 0 - rows = [] - - for period, count in statistics: - if calculation == 'cumulative_count': - total += count - value = total - else: - value = count - - rows.append({ - 'key': period.isoformat(), - 'label': period.isoformat(), - 'value': value, - }) - - return { - 'day': { - 'rows': rows, - }, - } - - -def get_category_statistics(statistics): - return { - 'rows': list(statistics), - } diff --git a/rdmo_plugins_statistics/views.py b/rdmo_plugins_statistics/views.py index c42be5b..a80f8ae 100644 --- a/rdmo_plugins_statistics/views.py +++ b/rdmo_plugins_statistics/views.py @@ -1,168 +1,33 @@ -from django.apps import apps -from django.conf import settings as django_settings -from django.contrib.auth import get_user_model from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.sites.models import Site -from django.db.models import Count, Q -from django.db.models.functions import TruncDay from django.template import TemplateDoesNotExist from django.template.loader import get_template from django.views.generic import TemplateView -from rdmo.projects.models import Project -from rdmo.questions.models import Catalog - -from .config import ( - CATEGORY_CHART_DEFINITION, - CATEGORY_CHART_SETTINGS, - TIME_CHART_DEFINITION, - TIME_CHART_SETTINGS, -) -from .utils import get_category_statistics, get_time_statistics +from .charts import compute_dashboard_charts +from .statistics import fetch_statistics class StatisticsView(PermissionRequiredMixin, TemplateView): template_name = 'rdmo_plugins_statistics/statistics.html' - permission_required = ( - apps.get_app_config('rdmo_plugins_statistics') - .navigation_items[0] - .get('permission') - ) + permission_required = 'statistics.view_statistics' raise_exception = True - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - + @staticmethod + def get_base_template(): try: get_template('core/bs53/base.html') - base_template = 'core/bs53/base.html' + return 'core/bs53/base.html' except TemplateDoesNotExist: - base_template = 'core/base.html' + return 'core/base.html' + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) current_site = Site.objects.get_current() - - User = get_user_model() - - time_chart_querysets = { - 'project': Project.objects.filter(site=current_site), - 'user': User.objects.filter(role__member=current_site), - } - custom_settings = getattr(django_settings, 'RDMO_STATISTICS', {}) - - time_charts = [] - - for name, definition in TIME_CHART_DEFINITION.items(): - queryset = time_chart_querysets[definition['query_key']] - - statistics = ( - queryset - .annotate(period=TruncDay(definition['date_field'])) - .values('period') - .annotate(count=Count('id')) - .values_list('period', 'count') - .order_by('period') - ) - - calculation = definition['calculation'] - - if calculation not in {'period_count', 'cumulative_count'}: - raise ValueError( - f'Unsupported time chart calculation: {calculation}' - ) - - statistics_data = get_time_statistics( - statistics, - calculation, - ) - - time_charts.append({ - **definition, - **{ - key: value - for key, value in custom_settings.get(name, {}).items() - if key in TIME_CHART_SETTINGS - }, - 'statistics': statistics_data, - 'total': queryset.count(), - }) - - category_chart_querysets = { - 'catalog': [ - { - 'key': catalog.id, - 'label': catalog.title, - 'value': catalog.count, - 'label_suffix': ' *' if not catalog.available else '', - } - for catalog in ( - Catalog.objects - .filter(sites=current_site) - .annotate( - count=Count( - 'projects', - filter=Q(projects__site=current_site), - ) - ) - .order_by('id') - ) - ], - 'project-progress': [ - { - 'key': project.id, - 'label': project.title, - 'value': round( - 100 * project.progress_count / - project.progress_total - ) if ( - project.progress_count is not None and - project.progress_total - ) else 0, - } - for project in ( - Project.objects - .filter(site=current_site) - .order_by('id') - ) - ], - } - - category_charts = [] - - for name, definition in CATEGORY_CHART_DEFINITION.items(): - queryset = category_chart_querysets[definition['query_key']] - - statistics_data = get_category_statistics(queryset) - - chart_settings = { - key: value - for key, value in custom_settings.get(name, {}).items() - if key in CATEGORY_CHART_SETTINGS - } - - chart = { - **definition, - **chart_settings, - 'statistics': statistics_data, - } - - opposite_orientation = { - 'horizontal': 'vertical', - 'vertical': 'horizontal', - }[definition['orientation']] - - if chart_settings.get('orientation') == opposite_orientation: - chart['x_axis_title'], chart['y_axis_title'] = ( - chart['y_axis_title'], - chart['x_axis_title'], - ) - - category_charts.append(chart) - context.update({ - 'base_template': base_template, + 'base_template': self.get_base_template(), 'current_site': current_site, - 'time_charts': time_charts, - 'category_charts': category_charts, + **compute_dashboard_charts(fetch_statistics(current_site)), }) return context diff --git a/rdmo_plugins_statistics/viewsets.py b/rdmo_plugins_statistics/viewsets.py new file mode 100644 index 0000000..7483981 --- /dev/null +++ b/rdmo_plugins_statistics/viewsets.py @@ -0,0 +1,59 @@ +from django.contrib.sites.models import Site + +from rdmo.core.permissions import HasPermission + +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet + +from .serializers import ( + CatalogStatisticsSerializer, + ProjectStatisticsSerializer, + StatisticsSerializer, + UserStatisticsSerializer, +) +from .statistics import ( + fetch_catalog_statistics, + fetch_project_statistics, + fetch_statistics, + fetch_user_statistics, +) + + +class StatisticsViewSet(GenericViewSet): + permission_classes = (HasPermission,) + permission_required = 'statistics.view_statistics' + serializer_class = StatisticsSerializer + + def list(self, request, *args, **kwargs): + statistics = fetch_statistics(Site.objects.get_current()) + return Response(self.get_serializer(statistics).data) + + +class ProjectStatisticsViewSet(GenericViewSet): + permission_classes = (HasPermission,) + permission_required = 'statistics.view_statistics' + serializer_class = ProjectStatisticsSerializer + + def list(self, request, *args, **kwargs): + statistics = fetch_project_statistics(Site.objects.get_current()) + return Response(self.get_serializer(statistics).data) + + +class UserStatisticsViewSet(GenericViewSet): + permission_classes = (HasPermission,) + permission_required = 'statistics.view_statistics' + serializer_class = UserStatisticsSerializer + + def list(self, request, *args, **kwargs): + statistics = fetch_user_statistics(Site.objects.get_current()) + return Response(self.get_serializer(statistics).data) + + +class CatalogStatisticsViewSet(GenericViewSet): + permission_classes = (HasPermission,) + permission_required = 'statistics.view_statistics' + serializer_class = CatalogStatisticsSerializer + + def list(self, request, *args, **kwargs): + statistics = fetch_catalog_statistics(Site.objects.get_current()) + return Response(self.get_serializer(statistics).data) diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/update_chartjs.py b/scripts/update_chartjs.py new file mode 100644 index 0000000..ffc1ab7 --- /dev/null +++ b/scripts/update_chartjs.py @@ -0,0 +1,61 @@ +import argparse +import re +from pathlib import Path +from tempfile import TemporaryDirectory +from urllib.request import Request, urlopen + +CHARTJS_DIR = ( + Path(__file__).resolve().parents[1] + / 'rdmo_plugins_statistics/static/rdmo_plugins_statistics/js' +) +CHARTJS_URL = 'https://cdn.jsdelivr.net/npm/chart.js@{version}' +VERSION_PATTERN = re.compile(r'\d+\.\d+\.\d+') + + +def download(url): + request = Request(url, headers={'User-Agent': 'rdmo-plugins-statistics vendor updater'}) + with urlopen(request, timeout=30) as response: + return response.read() + + +def update_chartjs(version, target_dir=CHARTJS_DIR): + if VERSION_PATTERN.fullmatch(version) is None: + raise ValueError('Chart.js version must use the form MAJOR.MINOR.PATCH.') + + base_url = CHARTJS_URL.format(version=version) + files = { + 'chart.umd.min.js': download(f'{base_url}/dist/chart.umd.min.js'), + 'chart.LICENSE.md': download(f'{base_url}/LICENSE.md'), + } + if f'Chart.js v{version}'.encode() not in files['chart.umd.min.js'][:500]: + raise ValueError('Downloaded JavaScript does not match the requested Chart.js version.') + if not all(marker in files['chart.LICENSE.md'] for marker in (b'MIT License', b'Chart.js Contributors')): + raise ValueError('Downloaded Chart.js license is not the expected MIT license.') + + target_dir = Path(target_dir) + target_dir.mkdir(parents=True, exist_ok=True) + with TemporaryDirectory(prefix='.chartjs-', dir=target_dir) as staging: + staging = Path(staging) + for name, content in files.items(): + (staging / name).write_bytes(content) + for name in files: + (staging / name).replace(target_dir / name) + + return tuple(target_dir / name for name in files) + + +def main(argv=None): + parser = argparse.ArgumentParser(description='Update the vendored Chart.js browser build and license.') + parser.add_argument('version', help='Stable Chart.js version, for example 4.5.1.') + options = parser.parse_args(argv) + try: + paths = update_chartjs(options.version) + except (OSError, ValueError) as exc: + parser.exit(1, f'error: {exc}\n') + print(f'Updated Chart.js {options.version}:') + for path in paths: + print(f' {path.relative_to(Path.cwd()) if path.is_relative_to(Path.cwd()) else path}') + + +if __name__ == '__main__': + main() diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..b0599d6 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,120 @@ +# Testing and development fixtures + +Install the development dependencies from a source checkout: + +```bash +pip install -e '.[dev]' +``` + +### Test users + +Create site members with registration and last-login dates distributed across a +time range: + +```bash +python manage.py runscript tests.scripts.bake_test_users \ + --script-args="--count 50 --seed 42 --batch-label september-users \ + --joined-start 2024-01-01T00:00:00+00:00 \ + --joined-end 2026-09-01T00:00:00+00:00 \ + --active-fraction 0.9 --last-login-fraction 0.7 \ + --manager-fraction 0.1 --editor-fraction 0.2" +``` + +The fractions independently control active accounts, staff status, last logins, +and RDMO manager, editor, and reviewer roles. Every generated user is a member of +the selected site. Use `--site-id` to select another site; otherwise the configured +current site is used. RDMO may additionally assign its current site through its +normal user-creation signal. + +Usernames follow `test_BATCH_NUMBER`, email addresses use the reserved +`example.invalid` domain, and passwords are unusable. Existing usernames with the +same normalized batch prefix cause the script to stop before creating anything. +The script writes a JSON report containing IDs, usernames, dates, flags, and roles. +The whole user batch is created in one transaction. + +The reusable function accepts the same controls: + +```python +from tests.helpers import create_test_users + +users = create_test_users( + site=site, + count=50, + batch_label='september-users', + seed=42, + joined_between=(start_datetime, end_datetime), + active_fraction=0.9, + staff_fraction=0.05, + manager_fraction=0.1, + editor_fraction=0.2, + reviewer_fraction=0.1, + last_login_fraction=0.7, +) +``` + +### Test projects + +The script uses an existing catalog and existing users belonging to +the selected site. It creates projects, explicit memberships, and current interview +values, then calculates progress with RDMO's answer tree: + +```bash +python manage.py runscript tests.scripts.bake_test_projects \ + --script-args="--catalog-id 12 --owner alice --member bob:author \ + --count 100 --seed 42 --batch-label september-demo \ + --answer-fraction 0.2 0.9 --collection-size 1 3 \ + --created-start 2025-01-01T00:00:00+00:00 \ + --created-end 2026-09-01T00:00:00+00:00" +``` + +Use `--site-id` to select another site; otherwise the configured current site is +used. Repeat `--owner` to distribute ownership across users. Repeat `--member` +to assign additional `owner`, `manager`, `author`, or `guest` memberships. Owners +and additional members must not overlap. No users or catalogs are created. + +The same implementation is available from Django shell or pytest: + +```python +from tests.helpers import create_test_projects + +reports = create_test_projects( + catalog=catalog, + site=site, + owners=[alice], + members=[(bob, 'author')], + count=10, + batch_label='interview-demo', + seed=42, + answer_fraction=1, + collection_size=(1, 2), + answer_overrides={ + # Keys are question URIs; values are Value field dictionaries. + trigger_question.uri: {'text': 'yes'}, + choice_question.uri: {'option': option}, + dynamic_question.uri: {'text': 'Fixture label', 'external_id': 'fixture:1'}, + }, +) +``` + +`fill_project_interview(project, ...)` also fills an existing empty project. +Overrides support `text`, `option`, and `external_id`, bypass automatic answer +selection, and are applied only when the question is visible. Supply semantically +valid payloads; overrides are not an interview-form validation API. + +Ordinary scalar answers, static options, repeated pages/question sets, and repeated +questions are supported. Conditions are evaluated using RDMO and revisited for up +to 32 passes as answers reveal questions. Answers subsequently hidden by conditions +remain stored, as in a normal interview. File questions, dynamic-only option sets, +and options requiring additional input are reported as skipped unless a supported +explicit payload is supplied. No remote option providers are called or files created. + +Answer fraction is a probability for attempting each eligible question location, +not a guaranteed progress percentage. JSON reports contain project IDs, value +counts, actual progress counts/totals, and skipped-question reasons. A seed makes +generated answer content reproducible for identical inputs/catalogs; IDs and +timestamps are not deterministic unless an explicit date range is provided. + +Projects are labelled `[test:BATCH]`; an existing batch label on the site is +rejected. Each project is created in a transaction. If a later project fails, +earlier completed projects remain and can be identified by that batch label. +The script does not overwrite or delete existing projects. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..83030ad --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,326 @@ +"""Create reproducible RDMO development fixtures with Model Bakery.""" + +from datetime import datetime +from decimal import ROUND_FLOOR, Decimal +from random import Random + +from django.contrib.auth import get_user_model +from django.db import transaction +from django.utils.text import slugify + +from rdmo.projects.answers import AnswerTree +from rdmo.projects.models import Membership, Project, Value +from rdmo.projects.progress import compute_progress + +from model_bakery import baker + + +def _range(value, minimum, maximum, name): + if not isinstance(value, tuple | list): + value = (value, value) + if len(value) != 2 or not minimum <= value[0] <= value[1] <= maximum: + raise ValueError(f'{name} must be between {minimum} and {maximum}.') + return value + + +def _fraction(value, name): + if not isinstance(value, int | float) or not 0 <= value <= 1: + raise ValueError(f'{name} must be between 0 and 1.') + return value + + +def _datetime_range(value, name): + if value is None: + return None + if not isinstance(value, tuple | list) or len(value) != 2: + raise ValueError(f'{name} requires two timezone-aware datetimes.') + start, end = value + if not all(isinstance(item, datetime) and item.utcoffset() is not None for item in value) or end < start: + raise ValueError(f'{name} requires two ordered timezone-aware datetimes.') + return start, end + + +@transaction.atomic +def create_test_users( + *, + site, + count=10, + seed=42, + batch_label='statistics-users', + joined_between=None, + active_fraction=1, + staff_fraction=0, + manager_fraction=0, + editor_fraction=0, + reviewer_fraction=0, + last_login_fraction=0.7, +): + """Create site members with reproducible account flags and registration dates. + + Passwords are deliberately unusable. Role fractions are independent, so a user + can receive more than one site role. The whole user batch is atomic. + """ + User = get_user_model() + if not isinstance(count, int) or count < 1: + raise ValueError('count must be a positive integer.') + label = slugify(batch_label) + if not label: + raise ValueError('batch_label must contain letters or numbers.') + fractions = { + 'active': _fraction(active_fraction, 'active_fraction'), + 'staff': _fraction(staff_fraction, 'staff_fraction'), + 'manager': _fraction(manager_fraction, 'manager_fraction'), + 'editor': _fraction(editor_fraction, 'editor_fraction'), + 'reviewer': _fraction(reviewer_fraction, 'reviewer_fraction'), + 'last_login': _fraction(last_login_fraction, 'last_login_fraction'), + } + dates = _datetime_range(joined_between, 'joined_between') + username_field = User._meta.get_field(User.USERNAME_FIELD) + suffix_length = len(str(count)) + prefix = f'test_{label}_' + if len(prefix) + suffix_length > username_field.max_length: + raise ValueError('batch_label is too long for the configured username field.') + if User.objects.filter(**{f'{User.USERNAME_FIELD}__startswith': prefix}).exists(): + raise ValueError('This user batch label already exists.') + + rng = Random(seed) + reports = [] + for index in range(1, count + 1): + username = f'{prefix}{index:0{suffix_length}d}' + joined = dates[0] + (dates[1] - dates[0]) * rng.random() if dates else None + fields = { + User.USERNAME_FIELD: username, + 'is_active': rng.random() < fractions['active'], + 'is_staff': rng.random() < fractions['staff'], + 'is_superuser': False, + } + if joined is not None: + fields['date_joined'] = joined + if any(field.name == 'email' for field in User._meta.fields): + fields['email'] = f'{username}@example.invalid' + if any(field.name == 'first_name' for field in User._meta.fields): + fields['first_name'] = 'Test' + if any(field.name == 'last_name' for field in User._meta.fields): + fields['last_name'] = f'User {index}' + if joined is not None and rng.random() < fractions['last_login']: + fields['last_login'] = joined + (dates[1] - joined) * rng.random() + else: + fields['last_login'] = None + + user = baker.make(User, **fields) + user.set_unusable_password() + user.save(update_fields=['password']) + user.role.member.add(site) + roles = ['member'] + for role_name in ('manager', 'editor', 'reviewer'): + if rng.random() < fractions[role_name]: + getattr(user.role, role_name).add(site) + roles.append(role_name) + reports.append({ + 'user_id': user.pk, + 'username': user.get_username(), + 'date_joined': user.date_joined.isoformat(), + 'last_login': user.last_login.isoformat() if user.last_login else None, + 'is_active': user.is_active, + 'is_staff': user.is_staff, + 'roles': roles, + }) + return reports + + +def _visible(element, values, location): + conditions = list(element.conditions.all()) + return not conditions or any( + condition.resolve(values, *location) if location else condition.resolve(values) + for condition in conditions + ) + + +def _answer(question, rng, values, location, overrides): + if question.uri in overrides: + payload = dict(overrides[question.uri]) + if payload.keys() - {'text', 'option', 'external_id'}: + raise ValueError(f'Unsupported answer override fields for {question.uri}.') + if payload.get('option') is not None and not question.optionsets.filter(options=payload['option']).exists(): + raise ValueError(f'Option does not belong to {question.uri}.') + return payload, None + + kind = question.value_type + if kind == 'option': + options = [] + for optionset in question.optionsets.all().order_by('pk'): + if not optionset.provider_key and _visible(optionset, values, location): + options.extend(optionset.options.order_by('pk')) + if not options: + return None, 'No eligible static options; supply an answer override.' + option = rng.choice(options) + if option.additional_input: + return None, 'Selected option needs additional input; supply an answer override.' + return {'option': option}, None + if kind in {'integer', 'float'}: + low = Decimal(str(question.minimum if question.minimum is not None else min(0, question.maximum or 0))) + high = Decimal(str(question.maximum if question.maximum is not None else max(low, 100))) + step = Decimal(str(question.step if question.step is not None else 1)) + if not all(x.is_finite() for x in (low, high, step)) or step <= 0 or high < low: + return None, 'Invalid numeric bounds or step.' + if kind == 'integer' and (low != low.to_integral() or step != step.to_integral()): + return None, 'Non-integral integer bounds/step; supply an answer override.' + steps = int(((high - low) / step).to_integral_value(rounding=ROUND_FLOOR)) + number = low + rng.randint(0, steps) * step + return {'text': str(number)}, None + text = { + 'text': f'Test answer {rng.randrange(100000)}', + 'boolean': str(rng.randrange(2)), + 'date': '2026-01-15', + 'datetime': '2026-01-15T12:00:00+00:00', + 'url': 'https://example.org/test', + 'email': 'test@example.org', + 'phone': '+49 30 123456', + }.get(kind) + if text is None: + return None, f'Unsupported value type: {kind}; no files or remote providers are generated.' + return {'text': text}, None + + +@transaction.atomic +def fill_project_interview(project, *, seed=42, answer_fraction=1, collection_size=1, answer_overrides=None): + """Fill an empty interview, retaining hidden answers like the real interview does. + + Overrides map question URIs to Value payloads (text, option, external_id). + Questions are attempted once per location; hidden questions are revisited. + """ + fraction = _range(answer_fraction, 0, 1, 'answer_fraction') + sizes = _range(collection_size, 1, 20, 'collection_size') + if any(not isinstance(size, int) for size in sizes): + raise ValueError('collection_size must contain integers.') + if project.catalog is None or project.values.exists(): + raise ValueError('An empty project with a catalog is required.') + rng = Random(seed) + fraction = rng.uniform(*fraction) + overrides = answer_overrides or {} + project.catalog.prefetch_elements() + questions = [e for e in project.catalog.descendants if e._meta.model_name == 'question'] + unknown = overrides.keys() - {q.uri for q in questions} + if unknown: + raise ValueError(f'Unknown question override URIs: {sorted(unknown)}') + attempted, occupied, seen = set(), set(), set() + set_sizes, skipped = {}, {} + + def visit(element, parent=None, parent_collection=False): + kind = element._meta.model_name + values = project.values.filter(snapshot=None) + if kind in {'page', 'questionset', 'question'} and not _visible(element, values, parent): + return + if kind in {'catalog', 'section'}: + for child in element.elements: + visit(child) + elif kind in {'page', 'questionset'}: + key = (kind, element.pk, parent) + if key not in set_sizes: + set_sizes[key] = rng.randint(*sizes) if element.is_collection else 1 + prefix = AnswerTree.compute_child_set_prefix(parent) + if len(prefix) > Value._meta.get_field('set_prefix').max_length: + raise ValueError('Catalog nesting exceeds the Value set_prefix limit.') + for index in range(set_sizes[key]): + for child in element.elements: + visit(child, (prefix, index), element.is_collection) + elif kind == 'question': + seen.add(element.uri) + key = (element.pk, parent) + if key in attempted: + return + attempted.add(key) + if element.attribute_id is None: + skipped[element.uri] = 'Question has no attribute.' + return + if element.uri not in overrides and rng.random() >= fraction: + return + count = rng.randint(*sizes) if element.is_collection else 1 + for index in range(count): + location = (element.attribute_id, *parent, index) + if location in occupied: + continue + payload, reason = _answer(element, rng, values, parent, overrides) + if reason: + skipped[element.uri] = reason + continue + baker.make( + Value, project=project, snapshot=None, attribute=element.attribute, + set_prefix=parent[0], set_index=parent[1], set_collection=parent_collection, + collection_index=index, value_type=element.value_type, unit=element.unit, + **({'text': '', 'option': None, 'external_id': '', 'file': None} | payload), + ) + occupied.add(location) + + for _ in range(32): + before = len(occupied) + visit(project.catalog) + if len(occupied) == before: + break + else: + raise ValueError('Conditional interview did not settle within 32 passes.') + for question in questions: + if question.uri not in seen: + skipped[question.uri] = 'Not visible under the generated answers or ancestor collections.' + project.progress_count, project.progress_total = compute_progress(project) + project.save(update_fields=['progress_count', 'progress_total']) + return { + 'project_id': project.pk, 'value_count': len(occupied), + 'progress_count': project.progress_count, 'progress_total': project.progress_total, + 'skipped': skipped, + } + + +def create_test_projects(*, catalog, site, owners, members=(), count=10, seed=42, + batch_label='statistics-demo', answer_fraction=(0.2, 0.9), + collection_size=(1, 3), created_between=None, answer_overrides=None): + """Create fixtures using existing users/catalog; return one report per project. + + Each project is atomic. Earlier projects remain if a later project fails. + Repeating a batch label is rejected, preventing accidental duplicate runs. + """ + owners, members = list(owners), list(members) + if not isinstance(count, int) or count < 1 or not owners: + raise ValueError('Supply a positive count and at least one owner.') + if not batch_label.strip() or len(batch_label) > 180: + raise ValueError('batch_label must contain 1-180 characters.') + if not catalog.available or not catalog.sites.filter(pk=site.pk).exists(): + raise ValueError('Catalog must be available and assigned to the selected site.') + roles = dict(Membership.ROLE_CHOICES) + if any(role not in roles for _, role in members): + raise ValueError('Unknown membership role.') + if len({u.pk for u, _ in members}) != len(members): + raise ValueError('Supply each additional member only once.') + if {u.pk for u in owners} & {u.pk for u, _ in members}: + raise ValueError('Owner pool and additional members must not overlap.') + for user in [*owners, *(u for u, _ in members)]: + if not user.pk or not user.role.member.filter(pk=site.pk).exists(): + raise ValueError(f'User {user} must belong to the selected site.') + _range(answer_fraction, 0, 1, 'answer_fraction') + _range(collection_size, 1, 20, 'collection_size') + dates = _datetime_range(created_between, 'created_between') + prefix = f'[test:{batch_label}] ' + if Project.objects.filter(site=site, title__startswith=prefix).exists(): + raise ValueError('This batch label already exists on the selected site.') + rng, reports = Random(seed), [] + for index in range(count): + with transaction.atomic(): + project = baker.make( + Project, site=site, catalog=catalog, parent=None, + title=f'{prefix}{index + 1:04d}', description='Generated test project.', + progress_count=None, progress_total=None, + ) + baker.make(Membership, project=project, user=owners[index % len(owners)], role='owner') + for user, role in members: + baker.make(Membership, project=project, user=user, role=role) + report = fill_project_interview( + project, seed=rng.randrange(2**32), answer_fraction=answer_fraction, + collection_size=collection_size, answer_overrides=answer_overrides, + ) + if dates: + start, end = dates + created = start + (end - start) * rng.random() + Project.objects.filter(pk=project.pk).update(created=created, updated=created) + project.values.update(created=created, updated=created) + reports.append(report) + return reports diff --git a/tests/scripts/__init__.py b/tests/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/scripts/bake_test_projects.py b/tests/scripts/bake_test_projects.py new file mode 100644 index 0000000..fb2f42f --- /dev/null +++ b/tests/scripts/bake_test_projects.py @@ -0,0 +1,63 @@ +import argparse +import json +import shlex +from datetime import datetime + +from django.contrib.auth import get_user_model +from django.contrib.sites.models import Site +from django.core.exceptions import ObjectDoesNotExist +from django.core.management.base import CommandError + +from rdmo.questions.models import Catalog + +from tests.helpers import create_test_projects + + +def get_arguments(args): + parser = argparse.ArgumentParser(description='Create catalog-based test projects and interview values.') + parser.add_argument('--catalog-id', type=int, required=True) + parser.add_argument('--site-id', type=int) + parser.add_argument('--owner', action='append', required=True, help='Username; repeat for an owner pool.') + parser.add_argument('--member', action='append', default=[], help='USERNAME:ROLE; repeat for extra members.') + parser.add_argument('--count', type=int, default=10) + parser.add_argument('--seed', type=int, default=42) + parser.add_argument('--batch-label', required=True) + parser.add_argument('--answer-fraction', type=float, nargs=2, default=(0.2, 0.9), metavar=('MIN', 'MAX')) + parser.add_argument('--collection-size', type=int, nargs=2, default=(1, 3), metavar=('MIN', 'MAX')) + parser.add_argument('--created-start', help='ISO datetime with timezone offset.') + parser.add_argument('--created-end', help='ISO datetime with timezone offset.') + return parser.parse_args(shlex.split(' '.join(args))) + + +def run(*args): + options = get_arguments(args) + try: + User = get_user_model() + site = Site.objects.get(pk=options.site_id) if options.site_id else Site.objects.get_current() + catalog = Catalog.objects.get(pk=options.catalog_id) + owners = [User.objects.get(**{User.USERNAME_FIELD: name}) for name in options.owner] + members = [] + for member in options.member: + name, role = member.rsplit(':', 1) + members.append((User.objects.get(**{User.USERNAME_FIELD: name}), role)) + if bool(options.created_start) != bool(options.created_end): + raise ValueError('Supply both --created-start and --created-end.') + dates = ( + (datetime.fromisoformat(options.created_start), datetime.fromisoformat(options.created_end)) + if options.created_start else None + ) + reports = create_test_projects( + catalog=catalog, + site=site, + owners=owners, + members=members, + count=options.count, + seed=options.seed, + batch_label=options.batch_label, + answer_fraction=options.answer_fraction, + collection_size=options.collection_size, + created_between=dates, + ) + except (ObjectDoesNotExist, ValueError) as exc: + raise CommandError(str(exc)) from exc + print(json.dumps(reports, indent=2)) diff --git a/tests/scripts/bake_test_users.py b/tests/scripts/bake_test_users.py new file mode 100644 index 0000000..7f51388 --- /dev/null +++ b/tests/scripts/bake_test_users.py @@ -0,0 +1,55 @@ +import argparse +import json +import shlex +from datetime import datetime + +from django.contrib.sites.models import Site +from django.core.exceptions import ObjectDoesNotExist +from django.core.management.base import CommandError + +from tests.helpers import create_test_users + + +def get_arguments(args): + parser = argparse.ArgumentParser(description='Create test users with varied dates and RDMO site roles.') + parser.add_argument('--site-id', type=int) + parser.add_argument('--count', type=int, default=10) + parser.add_argument('--seed', type=int, default=42) + parser.add_argument('--batch-label', required=True) + parser.add_argument('--joined-start', help='ISO datetime with timezone offset.') + parser.add_argument('--joined-end', help='ISO datetime with timezone offset.') + parser.add_argument('--active-fraction', type=float, default=1) + parser.add_argument('--staff-fraction', type=float, default=0) + parser.add_argument('--manager-fraction', type=float, default=0) + parser.add_argument('--editor-fraction', type=float, default=0) + parser.add_argument('--reviewer-fraction', type=float, default=0) + parser.add_argument('--last-login-fraction', type=float, default=0.7) + return parser.parse_args(shlex.split(' '.join(args))) + + +def run(*args): + options = get_arguments(args) + try: + site = Site.objects.get(pk=options.site_id) if options.site_id else Site.objects.get_current() + if bool(options.joined_start) != bool(options.joined_end): + raise ValueError('Supply both --joined-start and --joined-end.') + dates = ( + (datetime.fromisoformat(options.joined_start), datetime.fromisoformat(options.joined_end)) + if options.joined_start else None + ) + reports = create_test_users( + site=site, + count=options.count, + seed=options.seed, + batch_label=options.batch_label, + joined_between=dates, + active_fraction=options.active_fraction, + staff_fraction=options.staff_fraction, + manager_fraction=options.manager_fraction, + editor_fraction=options.editor_fraction, + reviewer_fraction=options.reviewer_fraction, + last_login_fraction=options.last_login_fraction, + ) + except (ObjectDoesNotExist, ValueError) as exc: + raise CommandError(str(exc)) from exc + print(json.dumps(reports, indent=2)) diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..26450d8 --- /dev/null +++ b/tests/settings.py @@ -0,0 +1,21 @@ +from rdmo.core.settings import * # noqa: F403 + +INSTALLED_APPS = [ + *INSTALLED_APPS, # noqa: F405 + 'rdmo_plugins_statistics', +] + +ROOT_URLCONF = 'tests.urls' + +SECRET_KEY = 'rdmo-plugins-statistics-tests' + +ALLOWED_HOSTS = ['testserver'] + +STATIC_ROOT = '/tmp/rdmo-plugins-statistics-static' + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + }, +} diff --git a/tests/test_generator.py b/tests/test_generator.py new file mode 100644 index 0000000..9298eae --- /dev/null +++ b/tests/test_generator.py @@ -0,0 +1,155 @@ +import json +from contextlib import redirect_stdout +from datetime import datetime, timezone +from io import StringIO + +import pytest + +from django.contrib.auth import get_user_model +from django.contrib.sites.models import Site +from django.core.management import call_command + +from rdmo.conditions.models import Condition +from rdmo.domain.models import Attribute +from rdmo.options.models import Option, OptionSet +from rdmo.projects.models import Project +from rdmo.projects.progress import compute_progress +from rdmo.questions.models import Catalog, Page, Question, QuestionSet, Section + +from model_bakery import baker + +from tests.helpers import create_test_projects + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def fixture_catalog(): + site = Site.objects.get_current() + owner = get_user_model().objects.create_user(username='fixture-owner') + catalog = baker.make(Catalog, uri_prefix='https://example.org', uri_path='catalog', available=True) + catalog.sites.add(site) + section = baker.make(Section, uri_prefix='https://example.org', uri_path='section') + page = baker.make(Page, uri_prefix='https://example.org', uri_path='page', attribute=None, is_collection=False) + catalog.sections.add(section) + section.pages.add(page) + return site, owner, catalog, page + + +def question(parent, name, kind='text', **kwargs): + attribute = baker.make(Attribute, uri_prefix='https://example.org', key=name, parent=None) + item = baker.make( + Question, uri_prefix='https://example.org', uri_path=name, attribute=attribute, + value_type=kind, widget_type='text', is_collection=False, is_optional=False, + minimum=None, maximum=None, step=None, **kwargs, + ) + parent.questions.add(item) + return item + + +def test_projects_memberships_dates_and_progress(fixture_catalog): + site, owner, catalog, page = fixture_catalog + question(page, 'text') + question(page, 'boolean', 'boolean') + guest = get_user_model().objects.create_user(username='fixture-guest') + date = datetime(2025, 1, 1, tzinfo=timezone.utc) + reports = create_test_projects( + catalog=catalog, site=site, owners=[owner], members=[(guest, 'guest')], count=2, + answer_fraction=1, collection_size=1, created_between=(date, date), + ) + assert len(reports) == 2 + for report in reports: + project = Project.objects.get(pk=report['project_id']) + assert project.catalog_id == catalog.pk and project.site_id == site.pk + assert project.created == date + assert set(project.memberships.values_list('user_id', 'role')) == {(owner.pk, 'owner'), (guest.pk, 'guest')} + assert compute_progress(project) == (2, 2) + assert (project.progress_count, project.progress_total) == (2, 2) + assert project.values.filter(snapshot=None).count() == 2 + + +def test_nested_collections_and_conditions(fixture_catalog): + site, owner, catalog, page = fixture_catalog + trigger = question(page, 'trigger') + nested = baker.make(QuestionSet, uri_prefix='https://example.org', uri_path='nested', is_collection=True, attribute=None) + page.questionsets.add(nested) + answer = question(nested, 'nested-answer') + answer.is_collection = True + answer.save() + condition = baker.make( + Condition, uri_prefix='https://example.org', uri_path='condition', source=trigger.attribute, + relation='eq', target_text='show', target_option=None, + ) + nested.conditions.add(condition) + reports = create_test_projects( + catalog=catalog, site=site, owners=[owner], count=1, answer_fraction=1, + collection_size=2, answer_overrides={trigger.uri: {'text': 'show'}}, + ) + project = Project.objects.get(pk=reports[0]['project_id']) + assert set(project.values.filter(attribute=answer.attribute).values_list( + 'set_prefix', 'set_index', 'collection_index', 'set_collection' + )) == {('0', i, j, True) for i in range(2) for j in range(2)} + assert compute_progress(project) == (3, 3) + + +def test_skips_and_reproducibility(fixture_catalog): + site, owner, catalog, page = fixture_catalog + question(page, 'text') + file_question = question(page, 'file', 'file') + outputs = [] + for label in ('one', 'two'): + report = create_test_projects( + catalog=catalog, site=site, owners=[owner], count=1, batch_label=label, + answer_fraction=1, seed=7, + )[0] + assert file_question.uri in report['skipped'] + outputs.append(list(Project.objects.get(pk=report['project_id']).values.values_list('text', flat=True))) + assert outputs[0] == outputs[1] + with pytest.raises(ValueError, match='already exists'): + create_test_projects(catalog=catalog, site=site, owners=[owner], batch_label='one') + assert Project.objects.count() == 2 + + +def test_command_and_validation(fixture_catalog): + site, owner, catalog, page = fixture_catalog + question(page, 'text') + out = StringIO() + with redirect_stdout(out): + call_command( + 'runscript', + 'tests.scripts.bake_test_projects', + script_args=[ + f'--catalog-id {catalog.pk} --owner {owner.username} ' + '--count 1 --batch-label command --answer-fraction 0 0' + ], + ) + report = json.loads(out.getvalue())[0] + assert report['value_count'] == 0 + assert report['progress_total'] == 1 + with pytest.raises(ValueError, match='Unknown question'): + create_test_projects(catalog=catalog, site=site, owners=[owner], answer_overrides={'missing': {}}) + assert Project.objects.count() == 1 + + +def test_numeric_static_options_and_hidden_question(fixture_catalog): + site, owner, catalog, page = fixture_catalog + number = question(page, 'number', 'float') + number.minimum, number.maximum, number.step = 2, 3, 0.25 + number.save() + choice = question(page, 'choice', 'option') + optionset = baker.make(OptionSet, uri_prefix='https://example.org', uri_path='choices', provider_key='') + option = baker.make(Option, uri_prefix='https://example.org', uri_path='only-option', additional_input='') + optionset.options.add(option) + choice.optionsets.add(optionset) + hidden = question(page, 'hidden') + hidden.conditions.add(baker.make( + Condition, uri_prefix='https://example.org', uri_path='never', source=number.attribute, + relation='eq', target_text='999', target_option=None, + )) + report = create_test_projects(catalog=catalog, site=site, owners=[owner], count=1, answer_fraction=1)[0] + project = Project.objects.get(pk=report['project_id']) + assert float(project.values.get(attribute=number.attribute).text) in {2, 2.25, 2.5, 2.75, 3} + assert project.values.get(attribute=choice.attribute).option == option + assert not project.values.filter(attribute=hidden.attribute).exists() + assert hidden.uri in report['skipped'] + assert compute_progress(project) == (2, 2) diff --git a/tests/test_statistics.py b/tests/test_statistics.py new file mode 100644 index 0000000..c3d6825 --- /dev/null +++ b/tests/test_statistics.py @@ -0,0 +1,384 @@ +import csv + +import pytest + +from django.contrib.auth import get_user_model +from django.contrib.sites.models import Site +from django.core.management import call_command +from django.core.management.base import CommandError +from django.test import override_settings +from django.urls import reverse +from django.utils import timezone + +from rdmo.projects.models import Project +from rdmo.questions.models import Catalog + +from rdmo_plugins_statistics.charts import compute_dashboard_summary, compute_project_progress_statistics +from rdmo_plugins_statistics.config import CATEGORY_CHART_DEFINITION +from rdmo_plugins_statistics.statistics import ( + fetch_statistics_for_sites, +) + + +def test_dashboard_summary_uses_existing_aggregates(): + statistics = { + 'projects': { + 'total': 8, + 'progress': [ + {'percentage': 50, 'count': 3}, + {'percentage': 100, 'count': 2}, + ], + }, + 'users': {'total': 5}, + 'catalogs': { + 'usage': [ + {'project_count': 4}, + {'project_count': 0}, + {'project_count': 1}, + ], + }, + } + + assert compute_dashboard_summary(statistics) == { + 'projects': 8, + 'users': 5, + 'catalogs_in_use': 2, + 'complete_projects': 2, + } + + statistics['projects']['progress'] = [{'percentage': 50, 'count': 3}] + assert compute_dashboard_summary(statistics)['complete_projects'] == 0 + + +def test_project_progress_statistics_uses_the_configured_group_size(): + statistics = [ + {'percentage': 0, 'count': 1}, + {'percentage': 4, 'count': 2}, + {'percentage': 5, 'count': 3}, + {'percentage': 99, 'count': 4}, + {'percentage': 100, 'count': 5}, + ] + + rows = compute_project_progress_statistics( + statistics, + CATEGORY_CHART_DEFINITION['project_progress']['progress_group_size'], + )['rows'] + + assert len(rows) == 11 + assert rows[0] == {'key': 0, 'label': '0-9%', 'value': 6} + assert rows[1] == {'key': 10, 'label': '10-19%', 'value': 0} + assert rows[9] == {'key': 90, 'label': '90-99%', 'value': 4} + assert rows[10] == {'key': 100, 'label': '100%', 'value': 5} + + +@pytest.mark.django_db +def test_statistics_requires_site_manager(client): + user = get_user_model().objects.create_user(username='member') + client.force_login(user) + + response = client.get(reverse('statistics:index')) + + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_statistics_page_uses_current_site_data(client): + current_site = Site.objects.get_current() + other_site = Site.objects.create(domain='other.example.com', name='Other site') + manager = get_user_model().objects.create_user(username='manager') + manager.role.manager.add(current_site) + + other_user = get_user_model().objects.create_user(username='other-user') + other_user.role.member.remove(current_site) + other_user.role.member.add(other_site) + + project = Project.objects.create( + site=current_site, + title='Private current project', + progress_count=1, + progress_total=2, + ) + Project.objects.create( + site=current_site, + title='Another private current project', + progress_count=1, + progress_total=2, + ) + other_project = Project.objects.create( + site=other_site, + title='Private other project', + progress_count=2, + progress_total=2, + ) + catalog = Catalog.objects.create( + uri_prefix='https://example.org', + uri_path='summary-catalog', + title_lang1='Summary catalog', + ) + catalog.sites.set([current_site, other_site]) + project.catalog = catalog + project.save(update_fields=['catalog']) + other_project.catalog = catalog + other_project.save(update_fields=['catalog']) + client.force_login(manager) + + response = client.get(reverse('statistics:index')) + + assert response.status_code == 200 + project_chart = next(chart for chart in response.context['time_charts'] if chart['key'] == 'project') + user_chart = next(chart for chart in response.context['time_charts'] if chart['key'] == 'user') + cumulative_users_chart = next( + chart for chart in response.context['time_charts'] if chart['key'] == 'cumulative-user' + ) + catalog_chart = next(chart for chart in response.context['category_charts'] if chart['key'] == 'catalog') + progress_chart = next(chart for chart in response.context['category_charts'] if chart['key'] == 'project-progress') + expected_date = timezone.localtime(project.created).date().isoformat() + + assert project_chart['total'] == 2 + assert project_chart['title'] == 'Number of projects over time' + assert project_chart['x_axis_title'] == 'Date of creation' + assert user_chart['title'] == 'Number of registered users over time' + assert user_chart['x_axis_title'] == 'Date of registration' + assert cumulative_users_chart['x_axis_title'] == 'Date of registration' + assert response.context['summary'] == { + 'projects': 2, + 'users': 1, + 'catalogs_in_use': 1, + 'complete_projects': 0, + } + assert project_chart['statistics']['day']['rows'] == [ + {'key': expected_date, 'label': expected_date, 'value': 2}, + ] + assert user_chart['total'] == 1 + assert set(catalog_chart['statistics']) == {'rows'} + assert catalog_chart['chart_type'] == 'bar' + assert len(progress_chart['statistics']['rows']) == 11 + assert progress_chart['statistics']['rows'][5] == {'key': 50, 'label': '50-59%', 'value': 2} + assert progress_chart['chart_type'] == 'bar' + assert progress_chart['orientation'] == 'vertical' + assert progress_chart['x_axis_title'] == 'Progress (%)' + assert progress_chart['y_axis_title'] == 'Number of projects' + assert b'data-chart-type="bar"' in response.content + assert b'data-chart-orientation="vertical"' in response.content + assert response.content.count(b'data-statistics-time-controls') == 1 + assert response.content.count(b'class="statistics-data-table"') == 5 + assert response.content.count(b'role="img"') == 5 + assert response.content.count(b'class="statistics-interval"') == 1 + assert b'