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
+ + + + + + + + + {% for item in top_modalities %} + + + + + {% endfor %} + {% if other_modality_count %} + + + + + {% endif %} + +
ModalityReports
{{ item.modality_code|default:"Unknown" }}{{ item.count }}
Other{{ other_modality_count }}
+
+
+
+ +
+
+
+
Top Languages
+ + + + + + + + + {% for item in top_languages %} + + + + + {% endfor %} + {% if other_language_count %} + + + + + {% endif %} + +
LanguageReports
{{ item.language_code }}{{ item.count }}
Other{{ other_language_count }}
+
+
+
+ +
+
+
+
Reports by Year (Clinical Time)
+ + + + + + + + + {% for item in year_counts %} + + + + + {% endfor %} + +
YearReports
{{ item.year }}{{ item.count }}
+
+
+
+
+{% 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