diff --git a/radis/chats/apps.py b/radis/chats/apps.py
index 85411afc..019a8a93 100644
--- a/radis/chats/apps.py
+++ b/radis/chats/apps.py
@@ -21,6 +21,7 @@ def register_app():
MainMenuItem(
url_name="chat_list",
label="Chats",
+ order=8,
)
)
diff --git a/radis/reports/apps.py b/radis/reports/apps.py
index ee411377..7e916ac0 100644
--- a/radis/reports/apps.py
+++ b/radis/reports/apps.py
@@ -6,10 +6,23 @@ class ReportsConfig(AppConfig):
name = "radis.reports"
def ready(self):
+ register_app()
# Put calls to db stuff in this signal handler
post_migrate.connect(init_db, sender=self)
+def register_app():
+ from adit_radis_shared.common.site import MainMenuItem, register_main_menu_item
+
+ register_main_menu_item(
+ MainMenuItem(
+ url_name="report_overview",
+ label="Overview",
+ order=9,
+ )
+ )
+
+
def init_db(**kwargs):
from .models import ReportsAppSettings
diff --git a/radis/reports/management/__init__.py b/radis/reports/management/__init__.py
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/radis/reports/management/__init__.py
@@ -0,0 +1 @@
+
diff --git a/radis/reports/management/commands/__init__.py b/radis/reports/management/commands/__init__.py
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/radis/reports/management/commands/__init__.py
@@ -0,0 +1 @@
+
diff --git a/radis/reports/management/commands/rebuild_report_overview_stats.py b/radis/reports/management/commands/rebuild_report_overview_stats.py
new file mode 100644
index 00000000..bfa52683
--- /dev/null
+++ b/radis/reports/management/commands/rebuild_report_overview_stats.py
@@ -0,0 +1,73 @@
+from django.contrib.auth.models import Group
+from django.core.management.base import BaseCommand
+from django.db import transaction
+from django.db.models import Count
+from django.db.models.functions import TruncYear
+
+from radis.reports.models import (
+ Report,
+ ReportLanguageStat,
+ ReportModalityStat,
+ ReportOverviewTotal,
+ ReportYearStat,
+)
+
+
+class Command(BaseCommand):
+ help = "Rebuild per-group report overview statistics."
+
+ def handle(self, *args, **options):
+ groups = Group.objects.all().only("id")
+ for group in groups:
+ report_qs = Report.objects.filter(groups=group)
+ total_count = report_qs.count()
+
+ year_counts = (
+ report_qs.annotate(year=TruncYear("study_datetime"))
+ .values("year")
+ .annotate(count=Count("id"))
+ .order_by()
+ )
+ modality_counts = (
+ report_qs.values("modalities__code")
+ .annotate(count=Count("id", distinct=True))
+ .order_by()
+ )
+ language_counts = (
+ report_qs.values("language__code").annotate(count=Count("id")).order_by()
+ )
+
+ year_stats = [
+ ReportYearStat(group=group, year=item["year"].year, count=item["count"])
+ for item in year_counts
+ if item["year"]
+ ]
+ modality_stats = [
+ ReportModalityStat(
+ group=group,
+ modality_code=item["modalities__code"] or "Unknown",
+ count=item["count"],
+ )
+ for item in modality_counts
+ ]
+ language_stats = [
+ ReportLanguageStat(
+ group=group,
+ language_code=item["language__code"] or "Unknown",
+ count=item["count"],
+ )
+ for item in language_counts
+ ]
+
+ with transaction.atomic():
+ ReportOverviewTotal.objects.update_or_create(
+ group=group, defaults={"total_count": total_count}
+ )
+ ReportYearStat.objects.filter(group=group).delete()
+ ReportModalityStat.objects.filter(group=group).delete()
+ ReportLanguageStat.objects.filter(group=group).delete()
+ ReportYearStat.objects.bulk_create(year_stats)
+ ReportModalityStat.objects.bulk_create(modality_stats)
+ ReportLanguageStat.objects.bulk_create(language_stats)
+
+ self.stdout.write(self.style.SUCCESS("Overview stats rebuilt."))
diff --git a/radis/reports/migrations/0014_reportoverviewtotal_reportlanguagestat_and_more.py b/radis/reports/migrations/0014_reportoverviewtotal_reportlanguagestat_and_more.py
new file mode 100644
index 00000000..aecabaa2
--- /dev/null
+++ b/radis/reports/migrations/0014_reportoverviewtotal_reportlanguagestat_and_more.py
@@ -0,0 +1,60 @@
+# Generated by Django 6.0.1 on 2026-01-26 20:16
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('auth', '0012_alter_user_first_name_max_length'),
+ ('reports', '0013_alter_report_options'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ReportOverviewTotal',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('total_count', models.PositiveIntegerField()),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='report_overview_total', to='auth.group')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='ReportLanguageStat',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('language_code', models.CharField(max_length=10)),
+ ('count', models.PositiveIntegerField()),
+ ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report_language_stats', to='auth.group')),
+ ],
+ options={
+ 'constraints': [models.UniqueConstraint(fields=('group', 'language_code'), name='unique_report_language_stat')],
+ },
+ ),
+ migrations.CreateModel(
+ name='ReportModalityStat',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('modality_code', models.CharField(max_length=16)),
+ ('count', models.PositiveIntegerField()),
+ ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report_modality_stats', to='auth.group')),
+ ],
+ options={
+ 'constraints': [models.UniqueConstraint(fields=('group', 'modality_code'), name='unique_report_modality_stat')],
+ },
+ ),
+ migrations.CreateModel(
+ name='ReportYearStat',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('year', models.PositiveIntegerField()),
+ ('count', models.PositiveIntegerField()),
+ ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report_year_stats', to='auth.group')),
+ ],
+ options={
+ 'constraints': [models.UniqueConstraint(fields=('group', 'year'), name='unique_report_year_stat')],
+ },
+ ),
+ ]
diff --git a/radis/reports/models.py b/radis/reports/models.py
index a56b0f54..c04e577a 100644
--- a/radis/reports/models.py
+++ b/radis/reports/models.py
@@ -104,3 +104,62 @@ class Meta:
def __str__(self) -> str:
return f"{self.key}: {self.value}"
+
+
+class ReportOverviewTotal(models.Model):
+ group = models.OneToOneField(
+ Group,
+ on_delete=models.CASCADE,
+ related_name="report_overview_total",
+ )
+ total_count = models.PositiveIntegerField()
+ updated_at = models.DateTimeField(auto_now=True)
+
+ def __str__(self) -> str:
+ return f"{self.group.pk} total={self.total_count}"
+
+
+class ReportYearStat(models.Model):
+ group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name="report_year_stats")
+ year = models.PositiveIntegerField()
+ count = models.PositiveIntegerField()
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(fields=["group", "year"], name="unique_report_year_stat")
+ ]
+
+ def __str__(self) -> str:
+ return f"{self.group.pk} {self.year}={self.count}"
+
+
+class ReportModalityStat(models.Model):
+ group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name="report_modality_stats")
+ modality_code = models.CharField(max_length=16)
+ count = models.PositiveIntegerField()
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(
+ fields=["group", "modality_code"], name="unique_report_modality_stat"
+ )
+ ]
+
+ def __str__(self) -> str:
+ return f"{self.group.pk} {self.modality_code}={self.count}"
+
+
+class ReportLanguageStat(models.Model):
+ group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name="report_language_stats")
+ language_code = models.CharField(max_length=10)
+ count = models.PositiveIntegerField()
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(
+ fields=["group", "language_code"], name="unique_report_language_stat"
+ )
+ ]
+
+ def __str__(self) -> str:
+ return f"{self.group.pk} {self.language_code}={self.count}"
diff --git a/radis/reports/tasks.py b/radis/reports/tasks.py
new file mode 100644
index 00000000..803a36cf
--- /dev/null
+++ b/radis/reports/tasks.py
@@ -0,0 +1,13 @@
+import logging
+
+from django.core.management import call_command
+from procrastinate.contrib.django import app
+
+logger = logging.getLogger(__name__)
+
+
+@app.periodic(cron="0 * * * *") # hourly
+@app.task
+def rebuild_report_overview_stats_task(*args, **kwargs) -> None:
+ logger.info("Rebuilding report overview stats")
+ call_command("rebuild_report_overview_stats")
diff --git a/radis/reports/templates/reports/report_overview.html b/radis/reports/templates/reports/report_overview.html
new file mode 100644
index 00000000..ed07d8f0
--- /dev/null
+++ b/radis/reports/templates/reports/report_overview.html
@@ -0,0 +1,133 @@
+{% extends "reports/reports_layout.html" %}
+{% block title %}
+ Reports Overview
+{% endblock title %}
+{% block heading %}
+
+{% endblock heading %}
+{% block content %}
+ {% if not stats_ready %}
+
+ Overview stats have not been generated yet. Ask an admin to run
+ uv run python manage.py rebuild_report_overview_stats.
+
+ {% endif %}
+
+
+
+
+
Total Reports
+
{{ total_count }}
+
+
+
+
+
+
+
{{ last_year }} Reports
+
{{ last_year_count }}
+
+
+
+
+
+
+
YoY Change ({{ last_year }} vs {{ prev_year }})
+
+ {% if yoy_change is not None %}
+ {% if yoy_change >= 0 %}+{% endif %}{{ yoy_change|floatformat:1 }}%
+ {% else %}
+ —
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
Top Modalities
+
+
+
+ | Modality |
+ Reports |
+
+
+
+ {% for item in top_modalities %}
+
+ | {{ item.modality_code|default:"Unknown" }} |
+ {{ item.count }} |
+
+ {% endfor %}
+ {% if other_modality_count %}
+
+ | Other |
+ {{ other_modality_count }} |
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
Top Languages
+
+
+
+ | Language |
+ Reports |
+
+
+
+ {% for item in top_languages %}
+
+ | {{ item.language_code }} |
+ {{ item.count }} |
+
+ {% endfor %}
+ {% if other_language_count %}
+
+ | Other |
+ {{ other_language_count }} |
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
Reports by Year (Clinical Time)
+
+
+
+ | Year |
+ Reports |
+
+
+
+ {% for item in year_counts %}
+
+ | {{ item.year }} |
+ {{ item.count }} |
+
+ {% endfor %}
+
+
+
+
+
+
+{% endblock content %}
diff --git a/radis/reports/tests/test_overview.py b/radis/reports/tests/test_overview.py
new file mode 100644
index 00000000..1d5c0298
--- /dev/null
+++ b/radis/reports/tests/test_overview.py
@@ -0,0 +1,60 @@
+from datetime import datetime
+from datetime import timezone as dt_timezone
+
+import pytest
+from adit_radis_shared.accounts.factories import GroupFactory, UserFactory
+from django.core.cache import cache
+from django.core.management import call_command
+from django.test import Client
+from django.utils import timezone
+
+from radis.reports.factories import ReportFactory
+
+
+@pytest.mark.django_db
+def test_report_overview_counts_are_group_scoped(client: Client):
+ cache.clear()
+ group = GroupFactory.create()
+ other_group = GroupFactory.create()
+ user = UserFactory.create(is_active=True)
+ user.groups.add(group)
+ user.active_group = group
+ user.save()
+
+ now = timezone.now()
+ last_year = now.year - 1
+ prev_year = now.year - 2
+
+ report_last_year = ReportFactory.create(
+ study_datetime=datetime(last_year, 1, 1, tzinfo=dt_timezone.utc)
+ )
+ report_last_year.groups.add(group)
+
+ report_prev_year = ReportFactory.create(
+ study_datetime=datetime(prev_year, 6, 1, tzinfo=dt_timezone.utc)
+ )
+ report_prev_year.groups.add(group)
+
+ report_other_group = ReportFactory.create(
+ study_datetime=datetime(last_year, 3, 1, tzinfo=dt_timezone.utc)
+ )
+ report_other_group.groups.add(other_group)
+
+ call_command("rebuild_report_overview_stats")
+
+ client.force_login(user)
+ response = client.get("/reports/overview/")
+
+ assert response.status_code == 200
+ assert response.context["total_count"] == 2
+ assert response.context["last_year_count"] == 1
+ assert response.context["prev_year_count"] == 1
+
+
+@pytest.mark.django_db
+def test_report_overview_requires_active_group(client: Client):
+ user = UserFactory.create(is_active=True)
+ client.force_login(user)
+
+ response = client.get("/reports/overview/")
+ assert response.status_code == 403
diff --git a/radis/reports/urls.py b/radis/reports/urls.py
index 882d99a4..fdaa2b0c 100644
--- a/radis/reports/urls.py
+++ b/radis/reports/urls.py
@@ -1,9 +1,10 @@
from django.urls import path
from rest_framework.urlpatterns import format_suffix_patterns
-from .views import ReportBodyView, ReportDetailView, ReportListView
+from .views import ReportBodyView, ReportDetailView, ReportListView, ReportOverviewView
urlpatterns = [
+ path("overview/", ReportOverviewView.as_view(), name="report_overview"),
path("", ReportListView.as_view(), name="report_list"),
path("/body/", ReportBodyView.as_view(), name="report_body"),
path("/", ReportDetailView.as_view(), name="report_detail"),
diff --git a/radis/reports/views.py b/radis/reports/views.py
index 00ff8520..4e3108dd 100644
--- a/radis/reports/views.py
+++ b/radis/reports/views.py
@@ -2,11 +2,19 @@
from adit_radis_shared.common.types import AuthenticatedHttpRequest
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.db.models import QuerySet
+from django.utils import timezone
+from django.views.generic.base import TemplateView
from django.views.generic.detail import DetailView
from django_filters.views import FilterView
from .filters import ReportFilter
-from .models import Report
+from .models import (
+ Report,
+ ReportLanguageStat,
+ ReportModalityStat,
+ ReportOverviewTotal,
+ ReportYearStat,
+)
class ReportListView(LoginRequiredMixin, PageSizeSelectMixin, FilterView):
@@ -41,3 +49,69 @@ def get_queryset(self) -> QuerySet[Report]:
class ReportBodyView(ReportDetailView):
template_name = "reports/report_body.html"
+
+
+class ReportOverviewView(LoginRequiredMixin, UserPassesTestMixin, TemplateView):
+ template_name = "reports/report_overview.html"
+ permission_denied_message = "You must be logged in and have an active group"
+ request: AuthenticatedHttpRequest
+
+ def test_func(self) -> bool | None:
+ return self.request.user.active_group is not None
+
+ def get_context_data(self, **kwargs: object) -> dict[str, object]:
+ context = super().get_context_data(**kwargs)
+ active_group = self.request.user.active_group
+ assert active_group
+
+ total_entry = ReportOverviewTotal.objects.filter(group=active_group).first()
+ total_count = total_entry.total_count if total_entry else 0
+
+ now = timezone.now()
+ last_year = now.year - 1
+ prev_year = now.year - 2
+ year_counts_by_year = dict(
+ ReportYearStat.objects.filter(group=active_group).values_list("year", "count")
+ )
+ last_year_count = year_counts_by_year.get(last_year, 0)
+ prev_year_count = year_counts_by_year.get(prev_year, 0)
+ yoy_change = None
+ if prev_year_count:
+ yoy_change = ((last_year_count - prev_year_count) / prev_year_count) * 100
+
+ year_window = 10
+ start_year = last_year - (year_window - 1)
+ year_range = list(range(start_year, last_year + 1))
+ year_counts = [
+ {"year": year, "count": year_counts_by_year.get(year, 0)} for year in year_range
+ ]
+
+ modality_counts = list(
+ ReportModalityStat.objects.filter(group=active_group).order_by("-count")
+ )
+ top_modalities = modality_counts[:5]
+ other_modality_count = sum(item.count for item in modality_counts[5:])
+
+ language_counts = list(
+ ReportLanguageStat.objects.filter(group=active_group).order_by("-count")
+ )
+ top_languages = language_counts[:10]
+ other_language_count = sum(item.count for item in language_counts[10:])
+
+ stats_ready = total_entry is not None
+ data = {
+ "total_count": total_count,
+ "last_year": last_year,
+ "prev_year": prev_year,
+ "last_year_count": last_year_count,
+ "prev_year_count": prev_year_count,
+ "yoy_change": yoy_change,
+ "year_counts": year_counts,
+ "top_modalities": top_modalities,
+ "other_modality_count": other_modality_count,
+ "top_languages": top_languages,
+ "other_language_count": other_language_count,
+ "stats_ready": stats_ready,
+ }
+ context.update(data)
+ return context