@@ -415,6 +415,22 @@ def user(self, obj):
415415# threshold is what separates a real failure from a cancel.
416416DOWNLOAD_FAILURE_MIN_STATUS = 400
417417
418+ # Sort key for the computed Outcome column: Completed, Cancelled, Failed, then single
419+ # files (which have no outcome). Attached via ``admin_order_field``, so the CASE only
420+ # runs when someone actually sorts by Outcome -- annotating it onto every queryset made
421+ # all the dashboard aggregates and counts pay for it on every page load.
422+ OUTCOME_SORT_ORDER = Case (
423+ When (zip_completed = True , then = Value (0 )), # Completed
424+ When (
425+ zip_completed = False ,
426+ status_code__gte = DOWNLOAD_FAILURE_MIN_STATUS ,
427+ then = Value (2 ), # Failed
428+ ),
429+ When (zip_completed = False , then = Value (1 )), # Cancelled
430+ default = Value (3 ), # single files have no outcome ('—')
431+ output_field = IntegerField (),
432+ )
433+
418434
419435class DownloadOutcomeFilter (SimpleListFilter ):
420436 """Filter zips by how they ended: completed, cancelled mid-stream, or failed."""
@@ -552,27 +568,10 @@ class DownloadEventsView(admin.ModelAdmin):
552568 'user_agent'
553569 )
554570 search_help_text = 'Search by username, full name, user or node guid, ip, path, storage provider, user or storage region, source area, user agent.'
555-
556- def get_queryset (self , request ):
557- """Annotate an outcome rank so the computed Outcome column is sortable.
558-
559- The rank mirrors :meth:`outcome` exactly. It's just an ordering key — it doesn't
560- change what rows are returned, so the table and the dashboard aggregates are
561- unaffected.
562- """
563- return super ().get_queryset (request ).annotate (
564- _outcome_rank = Case (
565- When (zip_completed = True , then = Value (0 )), # Completed
566- When (
567- zip_completed = False ,
568- status_code__gte = DOWNLOAD_FAILURE_MIN_STATUS ,
569- then = Value (2 ), # Failed
570- ),
571- When (zip_completed = False , then = Value (1 )), # Cancelled
572- default = Value (3 ), # single files have no outcome ('—')
573- output_field = IntegerField (),
574- )
575- )
571+ # Without this the admin runs a COUNT(*) over the ENTIRE table on every page load,
572+ # just to print "n total" next to the filtered count. At production volume that's a
573+ # full-table scan per view.
574+ show_full_result_count = False
576575
577576 @admin .display (description = 'User' , ordering = 'user__username' )
578577 def user_display (self , obj ):
@@ -593,7 +592,7 @@ def user_agent_display(self, obj):
593592 return '—'
594593 return obj .user_agent if len (obj .user_agent ) <= 80 else obj .user_agent [:79 ] + '…'
595594
596- @admin .display (description = 'Outcome' , ordering = '_outcome_rank' )
595+ @admin .display (description = 'Outcome' , ordering = OUTCOME_SORT_ORDER )
597596 def outcome (self , obj ):
598597 """Human-readable end state. Single files have no outcome — they're recorded at the
599598 redirect before any bytes move, so they never report completion."""
@@ -630,33 +629,38 @@ def changelist_view(self, request, extra_context=None):
630629
631630 if extra_context is None :
632631 extra_context = {}
633- changelist = self .get_changelist_instance (request )
634- extra_context ['download_events_dashboard' ] = self .get_dashboard_data (changelist .get_queryset (request ))
635- extra_context ['download_events_active_filters' ] = self ._active_filters (request )
636- return super ().changelist_view (request , extra_context = extra_context )
637-
638- # query-string param -> human label, for the "applied filters" banner above the charts
639- FILTER_LABELS = (
640- ('q' , 'Search' ),
641- ('project_guid' , 'Project' ),
642- ('download_user' , 'User' ),
643- ('download_type' , 'Download type' ),
644- ('outcome' , 'Outcome' ),
645- ('zip_completed__exact' , 'Zip completed' ),
646- ('storage_provider' , 'Storage provider' ),
647- )
648-
649- def _active_filters (self , request ):
632+ response = super ().changelist_view (request , extra_context = extra_context )
633+
634+ # Feed the charts from the ChangeList the admin just built rather than
635+ # constructing a second one -- every ChangeList runs the whole
636+ # filter/count/results pipeline in __init__, so two of them doubled all of
637+ # that work per page view. On non-changelist responses (e.g. the redirect
638+ # the admin issues for a bad filter value) there is no `cl` and the charts
639+ # are simply skipped, where the old code 500ed.
640+ context_data = getattr (response , 'context_data' , None ) or {}
641+ changelist = context_data .get ('cl' )
642+ if changelist is not None :
643+ response .context_data ['download_events_dashboard' ] = self .get_dashboard_data (changelist .queryset )
644+ response .context_data ['download_events_active_filters' ] = self ._active_filters (request , changelist )
645+ return response
646+
647+ def _active_filters (self , request , changelist ):
650648 """The filters/search currently in effect, as ``[{'label', 'value'}]``, so the
651649 dashboard can show at a glance what its numbers are scoped to.
652650
653- Purely presentational — it reads the same query string the changelist already
654- filtered on; it never changes what's queried.
651+ Read off the ChangeList's own filter specs, so the banner shows exactly what
652+ the filter panel shows -- same titles, same choice labels ('Yes', not '1';
653+ 'Single file', not 'file') -- and a filter added to ``list_filter`` shows up
654+ here on its own. A hand-kept param map drifted: it missed the ``__exact``
655+ params Django uses for choice fields and never learned about new filters.
656+
657+ Purely presentational; it never changes what's queried.
655658 """
656659 params = request .GET
657660 active = []
658661
659- # the date range arrives in date+time halves; recombine them into one readable line
662+ # The date range renders as a form, not choices, so its spec has nothing to
663+ # report -- recombine the date+time halves from the query string instead.
660664 date_from = ' ' .join (
661665 part for part in (params .get ('created__range__gte_0' ), params .get ('created__range__gte_1' )) if part
662666 )
@@ -666,10 +670,31 @@ def _active_filters(self, request):
666670 if date_from or date_to :
667671 active .append ({'label' : 'Date (UTC)' , 'value' : f"{ date_from or '…' } → { date_to or 'now' } " })
668672
669- for param , label in self .FILTER_LABELS :
670- value = params .get (param )
671- if value :
672- active .append ({'label' : label , 'value' : value })
673+ # the search box isn't a filter spec either
674+ if params .get ('q' ):
675+ active .append ({'label' : 'Search' , 'value' : params ['q' ]})
676+
677+ for spec in changelist .filter_specs :
678+ title = str (spec .title )
679+ label = title [:1 ].upper () + title [1 :]
680+ if isinstance (spec , InputFilter ):
681+ # free-text filters override choices() for their form, so read the
682+ # typed value directly
683+ if spec .value ():
684+ active .append ({'label' : label , 'value' : spec .value ()})
685+ continue
686+ try :
687+ choices = list (spec .choices (changelist ))
688+ except Exception :
689+ # a spec that can't enumerate choices (form-based filters) simply
690+ # doesn't contribute a chip; never let the banner break the page
691+ continue
692+ active .extend (
693+ {'label' : label , 'value' : str (choice .get ('display' , '' ))}
694+ # the first choice is always "All" -- selected means the filter is off
695+ for choice in choices [1 :]
696+ if choice .get ('selected' ) and choice .get ('display' )
697+ )
673698 return active
674699
675700 def _in_dashboard_group (self , request ):
@@ -713,9 +738,6 @@ def has_delete_permission(self, request, obj=None):
713738 # genuinely terabyte-scale, so small numbers aren't cluttered with "(0.0 TB)".
714739 GB_PER_TB = 1024
715740
716- def _sum_bytes (self , queryset ):
717- return queryset .aggregate (total_bytes = Sum ('size_bytes' ))['total_bytes' ] or 0
718-
719741 def _to_gb (self , total_bytes ):
720742 return round ((total_bytes or 0 ) / (1024 ** 3 ), 2 )
721743
@@ -736,15 +758,39 @@ def _percent(self, part, whole):
736758 return round (part * 100 / whole , 2 )
737759
738760 def get_dashboard_data (self , queryset ):
739- file_queryset = queryset .filter (download_type = DownloadEvent .FILE )
740- zip_queryset = queryset .exclude (download_type = DownloadEvent .FILE )
741- total_file_downloads = file_queryset .count ()
742- total_zip_downloads = zip_queryset .count ()
743- total_bytes = self ._sum_bytes (queryset )
744-
745- total_downloads = queryset .count ()
746- total_file_gb = self ._to_gb (self ._sum_bytes (file_queryset ))
747- total_zip_gb = self ._to_gb (self ._sum_bytes (zip_queryset ))
761+ # The changelist hands this queryset over ordered by `created`, and Django folds
762+ # ordering columns into the GROUP BY of values().annotate(). Since `created` is
763+ # near-unique, that exploded every breakdown below into one row per EVENT rather
764+ # than one per group -- harmless on an hour of data, a timeout on a day of it.
765+ # Aggregates have no use for an ordering; drop it before anything else runs.
766+ queryset = queryset .order_by ()
767+
768+ is_file = Q (download_type = DownloadEvent .FILE )
769+ # Every headline number in one pass over the range -- these were twelve separate
770+ # scans of the same rows before.
771+ totals = queryset .aggregate (
772+ total_downloads = Count ('id' ),
773+ total_bytes = Sum ('size_bytes' ),
774+ file_downloads = Count ('id' , filter = is_file ),
775+ file_bytes = Sum ('size_bytes' , filter = is_file ),
776+ zip_downloads = Count ('id' , filter = ~ is_file ),
777+ zip_bytes = Sum ('size_bytes' , filter = ~ is_file ),
778+ # Zip outcomes. Single files are recorded before any bytes move, so they
779+ # have no outcome and are left out of these entirely.
780+ completed_zips = Count ('id' , filter = ~ is_file & Q (zip_completed = True )),
781+ incomplete_zips = Count ('id' , filter = ~ is_file & Q (zip_completed = False )),
782+ failed_zips = Count ('id' , filter = ~ is_file & Q (
783+ zip_completed = False , status_code__gte = DOWNLOAD_FAILURE_MIN_STATUS )),
784+ # distinct non-null users; Count skips NULLs, so anonymous rows don't count
785+ unique_users = Count ('user_id' , distinct = True ),
786+ )
787+ total_downloads = totals ['total_downloads' ]
788+ total_file_downloads = totals ['file_downloads' ]
789+ total_zip_downloads = totals ['zip_downloads' ]
790+ failed_zips = totals ['failed_zips' ]
791+ total_file_gb = self ._to_gb (totals ['file_bytes' ])
792+ total_zip_gb = self ._to_gb (totals ['zip_bytes' ])
793+
748794 time_series = self ._build_time_series (queryset )
749795 # each breakdown returns the ranked *known* regions plus, separately, the
750796 # "Unknown" bucket — so a large Unknown doesn't crowd real regions off the chart
@@ -753,20 +799,14 @@ def get_dashboard_data(self, queryset):
753799 # downloads and GB grouped by where the bytes came from (osfstorage vs addons)
754800 storage_providers , storage_providers_unknown = self ._build_region_breakdown (queryset , 'storage_provider' )
755801
756- # Zip outcomes. Single files are recorded before any bytes move, so they have no
757- # outcome and are left out of this breakdown entirely.
758- completed_zips = zip_queryset .filter (zip_completed = True ).count ()
759- failed_zips = zip_queryset .filter (
760- zip_completed = False , status_code__gte = DOWNLOAD_FAILURE_MIN_STATUS ).count ()
761- incomplete_zips = zip_queryset .filter (zip_completed = False ).count ()
762802 zip_outcomes = {
763- 'completed' : completed_zips ,
803+ 'completed' : totals [ ' completed_zips' ] ,
764804 # everything that didn't complete and wasn't a server failure is a user cancel
765- 'cancelled' : incomplete_zips - failed_zips ,
805+ 'cancelled' : totals [ ' incomplete_zips' ] - failed_zips ,
766806 'failed' : failed_zips ,
767807 }
768808
769- total_gb = self ._to_gb (total_bytes )
809+ total_gb = self ._to_gb (totals [ ' total_bytes' ] )
770810 split = {
771811 'file' : {
772812 'count' : total_file_downloads ,
@@ -789,7 +829,7 @@ def get_dashboard_data(self, queryset):
789829 'total_downloads' : total_downloads ,
790830 'total_gb' : total_gb ,
791831 'total_gb_tb_suffix' : self ._tb_suffix (total_gb ),
792- 'unique_users' : queryset . exclude ( user_id__isnull = True ). values ( 'user_id' ). distinct (). count () ,
832+ 'unique_users' : totals [ 'unique_users' ] ,
793833 'failed_zips' : failed_zips ,
794834 },
795835 'split' : split ,
@@ -970,8 +1010,8 @@ def _build_channel_breakdown(self, queryset):
9701010 total_bytes = Sum ('size_bytes' ),
9711011 )
9721012 labels = dict (DownloadEvent .DOWNLOAD_CHANNELS )
973- # Fold by channel in Python: a pre-existing annotation on the queryset (e.g. the
974- # sort's _outcome_rank) can leak into the GROUP BY and split a channel across rows,
1013+ # Fold by channel in Python: anything that sneaks extra columns into the GROUP BY
1014+ # (an inherited ordering, a stray annotation) would split a channel across rows,
9751015 # so sum them back together — same reason _build_region_breakdown folds.
9761016 folded = defaultdict (lambda : {'downloads' : 0 , 'bytes' : 0 })
9771017 for row in rows :
0 commit comments