-
Notifications
You must be signed in to change notification settings - Fork 3
Add a reports Overview page #189
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,7 @@ def register_app(): | |
| MainMenuItem( | ||
| url_name="chat_list", | ||
| label="Chats", | ||
| order=8, | ||
| ) | ||
| ) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
|
|
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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") | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using
Suggested change
|
||||||
| 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.")) | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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')], | ||
| }, | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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") | ||||||||||||||||||||||
|
Comment on lines
+9
to
+13
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Find and examine the file
find . -name "tasks.py" -path "*/reports/*" | head -5Repository: openradx/radis Length of output: 82 🏁 Script executed: # Check the actual content around lines 9-13
cat -n radis/reports/tasks.py | sed -n '1,20p'Repository: openradx/radis Length of output: 516 🌐 Web query:
💡 Result: In Procrastinate, a job stores and replays only keyword arguments ( Practical implications for task signatures and decorators:
Sources: Procrastinate docs/reference and how-tos. [1], [3], [5] 🏁 Script executed: # Check Ruff configuration
find . -name "ruff.toml" -o -name "pyproject.toml" -o -name ".ruff.toml" | head -5Repository: openradx/radis Length of output: 116 🏁 Script executed: # Look for ruff config in pyproject.toml or similar
if [ -f "pyproject.toml" ]; then
grep -A 20 "\[tool.ruff" pyproject.toml | head -30
fiRepository: openradx/radis Length of output: 402 🏁 Script executed: # Search for other task definitions in the codebase to check for consistency
rg "@app\.task|@app\.periodic" --type py -A 2 | head -50Repository: openradx/radis Length of output: 1677 **Remove unused *args and kwargs from periodic task signature. Line 11 defines unused parameters that Ruff flags as ARG001. Procrastinate periodic tasks receive no positional or keyword arguments from the scheduler, so these can be safely removed. 🛠️ Suggested fix `@app.periodic`(cron="0 * * * *") # hourly
`@app.task`
-def rebuild_report_overview_stats_task(*args, **kwargs) -> None:
+def rebuild_report_overview_stats_task() -> None:
logger.info("Rebuilding report overview stats")
call_command("rebuild_report_overview_stats")📝 Committable suggestion
Suggested change
🧰 Tools🪛 Ruff (0.14.14)11-11: Unused function argument: (ARG001) 11-11: Unused function argument: (ARG001) 🤖 Prompt for AI Agents |
||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| {% extends "reports/reports_layout.html" %} | ||
| {% block title %} | ||
| Reports Overview | ||
| {% endblock title %} | ||
| {% block heading %} | ||
| <c-page-heading title="Reports Overview" /> | ||
| {% endblock heading %} | ||
| {% block content %} | ||
| {% if not stats_ready %} | ||
| <div class="alert alert-warning"> | ||
| Overview stats have not been generated yet. Ask an admin to run | ||
| <code>uv run python manage.py rebuild_report_overview_stats</code>. | ||
| </div> | ||
| {% endif %} | ||
| <div class="row g-3 mb-4"> | ||
| <div class="col-md-4"> | ||
| <div class="card h-100"> | ||
| <div class="card-body"> | ||
| <h6 class="card-title text-muted">Total Reports</h6> | ||
| <div class="display-6">{{ total_count }}</div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| <div class="col-md-4"> | ||
| <div class="card h-100"> | ||
| <div class="card-body"> | ||
| <h6 class="card-title text-muted">{{ last_year }} Reports</h6> | ||
| <div class="display-6">{{ last_year_count }}</div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| <div class="col-md-4"> | ||
| <div class="card h-100"> | ||
| <div class="card-body"> | ||
| <h6 class="card-title text-muted">YoY Change ({{ last_year }} vs {{ prev_year }})</h6> | ||
| <div class="display-6"> | ||
| {% if yoy_change is not None %} | ||
| {% if yoy_change >= 0 %}+{% endif %}{{ yoy_change|floatformat:1 }}% | ||
| {% else %} | ||
| — | ||
| {% endif %} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="row g-4"> | ||
| <div class="col-lg-4"> | ||
| <div class="card h-100"> | ||
| <div class="card-body"> | ||
| <h5 class="card-title">Top Modalities</h5> | ||
| <table class="table table-sm"> | ||
| <thead> | ||
| <tr> | ||
| <th>Modality</th> | ||
| <th class="text-end">Reports</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| {% for item in top_modalities %} | ||
| <tr> | ||
| <td>{{ item.modality_code|default:"Unknown" }}</td> | ||
| <td class="text-end">{{ item.count }}</td> | ||
| </tr> | ||
| {% endfor %} | ||
| {% if other_modality_count %} | ||
| <tr> | ||
| <td>Other</td> | ||
| <td class="text-end">{{ other_modality_count }}</td> | ||
| </tr> | ||
| {% endif %} | ||
| </tbody> | ||
| </table> | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="col-lg-4"> | ||
| <div class="card h-100"> | ||
| <div class="card-body"> | ||
| <h5 class="card-title">Top Languages</h5> | ||
| <table class="table table-sm"> | ||
| <thead> | ||
| <tr> | ||
| <th>Language</th> | ||
| <th class="text-end">Reports</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| {% for item in top_languages %} | ||
| <tr> | ||
| <td>{{ item.language_code }}</td> | ||
| <td class="text-end">{{ item.count }}</td> | ||
| </tr> | ||
| {% endfor %} | ||
| {% if other_language_count %} | ||
| <tr> | ||
| <td>Other</td> | ||
| <td class="text-end">{{ other_language_count }}</td> | ||
| </tr> | ||
| {% endif %} | ||
| </tbody> | ||
| </table> | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="col-lg-4"> | ||
| <div class="card h-100"> | ||
| <div class="card-body"> | ||
| <h5 class="card-title">Reports by Year (Clinical Time)</h5> | ||
| <table class="table table-sm"> | ||
| <thead> | ||
| <tr> | ||
| <th>Year</th> | ||
| <th class="text-end">Reports</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| {% for item in year_counts %} | ||
| <tr> | ||
| <td>{{ item.year }}</td> | ||
| <td class="text-end">{{ item.count }}</td> | ||
| </tr> | ||
| {% endfor %} | ||
| </tbody> | ||
| </table> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| {% endblock content %} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The imports for
MainMenuItemandregister_main_menu_itemare placed inside theregister_appfunction. While this works, it's generally considered best practice in Python to place all imports at the top of the module for clarity and to avoid potential circular import issues or repeated imports if the function were called multiple times in different contexts. Moving these imports to the top of theapps.pyfile would improve code readability and consistency.