From 079c820da0a525ff01e58fedf38bc630e91c880f Mon Sep 17 00:00:00 2001 From: Sanjin <102841251+duckduckgrayduck@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:01:52 -0500 Subject: [PATCH 1/3] Optimize stats_api by storing certain fields and recomputing nightly --- changes.txt | 329 ++++++++++++++++++ config/settings/base.py | 5 + .../serverless/tests/test_error_handling.py | 2 +- .../commands/recompute_user_and_org_stats.py | 53 +++ documentcloud/core/tasks.py | 9 + ...ationstats_recent_upload_count_and_more.py | 23 ++ .../organizations/stats_api/models.py | 2 + .../organizations/stats_api/serializers.py | 10 +- .../organizations/stats_api/views.py | 64 ++-- ..._userstats_recent_upload_count_and_more.py | 23 ++ documentcloud/users/stats_api/models.py | 2 + documentcloud/users/stats_api/serializers.py | 10 +- documentcloud/users/stats_api/views.py | 73 ++-- 13 files changed, 498 insertions(+), 107 deletions(-) create mode 100644 changes.txt create mode 100644 documentcloud/core/management/commands/recompute_user_and_org_stats.py create mode 100644 documentcloud/core/tasks.py create mode 100644 documentcloud/organizations/migrations/0025_organizationstats_recent_upload_count_and_more.py create mode 100644 documentcloud/users/migrations/0015_userstats_recent_upload_count_and_more.py diff --git a/changes.txt b/changes.txt new file mode 100644 index 00000000..5bbe9495 --- /dev/null +++ b/changes.txt @@ -0,0 +1,329 @@ +diff --git a/config/settings/base.py b/config/settings/base.py +index a55ab8c2..49eb66ae 100644 +--- a/config/settings/base.py ++++ b/config/settings/base.py +@@ -398,6 +398,11 @@ CELERY_BEAT_SCHEDULE = { + "task": "documentcloud.users.tasks.permission_digest", + "schedule": crontab(day_of_week="mon", hour=7, minute=0), + }, ++ "recompute_user_and_org_stats": { ++ "task": "documentcloud.core.tasks.recompute_user_and_org_stats", ++ "schedule": crontab(hour=2, minute=0), ++ "options": {"time_limit": 1800, "soft_time_limit": 1740}, ++ }, + } + + PERMISSIONS_DIGEST_EMAILS = env.list("PERMISSIONS_DIGEST_EMAILS", default=[]) +diff --git a/documentcloud/organizations/stats_api/models.py b/documentcloud/organizations/stats_api/models.py +index 751b1077..93fcc280 100644 +--- a/documentcloud/organizations/stats_api/models.py ++++ b/documentcloud/organizations/stats_api/models.py +@@ -11,6 +11,8 @@ class OrganizationStats(models.Model): + ) + last_upload_at = models.DateTimeField(null=True, blank=True, db_index=True) + last_ai_credit_at = models.DateTimeField(null=True, blank=True, db_index=True) ++ total_documents = models.IntegerField(default=0) ++ recent_upload_count = models.IntegerField(default=0) + + class Meta: + app_label = "organizations" +diff --git a/documentcloud/organizations/stats_api/serializers.py b/documentcloud/organizations/stats_api/serializers.py +index 558576d4..a5bad06b 100644 +--- a/documentcloud/organizations/stats_api/serializers.py ++++ b/documentcloud/organizations/stats_api/serializers.py +@@ -10,9 +10,10 @@ class OrganizationStatsSerializer(serializers.ModelSerializer): + uuid = serializers.UUIDField(source="organization.uuid", read_only=True) + total_documents = serializers.IntegerField(read_only=True) + days_since_last_upload = serializers.SerializerMethodField() +- recent_upload_count = serializers.SerializerMethodField( +- help_text="Documents uploaded by the org within the configured window " +- "(UPLOAD_WINDOW_DAYS, defaults to 90)." ++ recent_upload_count = serializers.IntegerField( ++ read_only=True, ++ help_text="Number of documents uploaded within the configured recent window " ++ "(UPLOAD_WINDOW_DAYS, currently defaults to 90)." + ) + ai_credits = serializers.SerializerMethodField() + +@@ -34,9 +35,6 @@ class OrganizationStatsSerializer(serializers.ModelSerializer): + return None + return (timezone.now() - obj.last_upload_at).days + +- def get_recent_upload_count(self, obj): +- return getattr(obj, "recent_upload_count", None) +- + def get_ai_credits(self, obj): + org = obj.organization + return { +diff --git a/documentcloud/organizations/stats_api/views.py b/documentcloud/organizations/stats_api/views.py +index d8d6ed2d..422a3e05 100644 +--- a/documentcloud/organizations/stats_api/views.py ++++ b/documentcloud/organizations/stats_api/views.py +@@ -1,6 +1,6 @@ + # Django + from django.conf import settings +-from django.db.models import Count, Q ++from django.db.models import Exists, OuterRef + from django.utils import timezone + from django.utils.dateparse import parse_datetime + from rest_framework import mixins, viewsets +@@ -16,7 +16,7 @@ from django_filters import rest_framework as django_filters + + # DocumentCloud + from documentcloud.core.pagination import CursorPagination +-from documentcloud.documents.choices import Status ++from documentcloud.documents.models import Document + from documentcloud.organizations.stats_api.models import OrganizationStats + from documentcloud.organizations.stats_api.serializers import ( + OrganizationStatsSerializer, +@@ -58,64 +58,42 @@ class OrganizationStatsViewSet( + filterset_class = Filter + + def get_queryset(self): +- return OrganizationStats.objects.select_related("organization").filter( +- organization__individual=False +- ) +- +- def _annotate_and_prefetch(self, queryset): +- cutoff = timezone.now() - timedelta(days=settings.UPLOAD_WINDOW_DAYS) + return ( +- queryset.select_related("organization", "organization__parent") +- .prefetch_related("organization__groups") +- .annotate( +- total_documents=Count( +- "organization__documents", +- filter=~Q(organization__documents__status=Status.deleted), +- distinct=True, +- ), +- recent_upload_count=Count( +- "organization__documents", +- filter=Q(organization__documents__created_at__gte=cutoff) +- & ~Q(organization__documents__status=Status.deleted), +- distinct=True, +- ), ++ OrganizationStats.objects.select_related( ++ "organization", "organization__parent" + ) ++ .prefetch_related("organization__groups") ++ .filter(organization__individual=False) + ) + +- def paginate_queryset(self, queryset): +- page = super().paginate_queryset(queryset) +- annotated = self._annotate_and_prefetch( +- OrganizationStats.objects.filter(pk__in=[o.pk for o in page]) +- ).order_by("pk") +- return list(annotated) +- +- def get_object(self): +- obj = super().get_object() +- return self._annotate_and_prefetch( +- OrganizationStats.objects.filter(pk=obj.pk) +- ).get() +- + @action(detail=False, methods=["get"]) + def aged_out(self, request): +- """Orgs with a document that crossed the window boundary since `since`, +- so their upload count has dropped and needs re-syncing.""" ++ """Orgs with a document that crossed the recent-upload window boundary ++ since `since`, so their recent_upload_count has dropped without any event. ++ Lets the caller (Squarelet) know which orgs to re-sync. ++ """ + since = request.query_params.get("since") + if not since: + return Response({"error": "since query param is required"}, status=400) + since_dt = parse_datetime(since) + if since_dt is None: + return Response({"error": "since must be an ISO 8601 datetime"}, status=400) ++ if timezone.is_naive(since_dt): ++ since_dt = timezone.make_aware(since_dt, timezone.utc) + + win = timedelta(days=settings.UPLOAD_WINDOW_DAYS) + now = timezone.now() +- qs = ( +- self.get_queryset() +- .filter( +- organization__documents__created_at__gte=since_dt - win, +- organization__documents__created_at__lt=now - win, +- ) +- .distinct() ++ ++ # Exists() short-circuits per org instead of joining + distinct over a ++ # heavy org's whole document set which can time out. ++ aged_doc = Document.objects.filter( ++ organization_id=OuterRef("organization_id"), ++ created_at__gte=since_dt - win, ++ created_at__lt=now - win, + ) ++ qs = self.get_queryset().filter(Exists(aged_doc)) + + page = self.paginate_queryset(qs) +- return self.get_paginated_response(self.get_serializer(page, many=True).data) ++ return self.get_paginated_response( ++ self.get_serializer(page, many=True).data ++ ) +diff --git a/documentcloud/users/stats_api/models.py b/documentcloud/users/stats_api/models.py +index ecd384be..f4950ecd 100644 +--- a/documentcloud/users/stats_api/models.py ++++ b/documentcloud/users/stats_api/models.py +@@ -11,6 +11,8 @@ class UserStats(models.Model): + ) + last_upload_at = models.DateTimeField(null=True, blank=True, db_index=True) + last_ai_credit_at = models.DateTimeField(null=True, blank=True, db_index=True) ++ total_documents = models.IntegerField(default=0) ++ recent_upload_count = models.IntegerField(default=0) + + class Meta: + app_label = "users" +diff --git a/documentcloud/users/stats_api/serializers.py b/documentcloud/users/stats_api/serializers.py +index a7452f93..319e3c3b 100644 +--- a/documentcloud/users/stats_api/serializers.py ++++ b/documentcloud/users/stats_api/serializers.py +@@ -8,12 +8,13 @@ from documentcloud.users.stats_api.models import UserStats + + class UserStatsSerializer(serializers.ModelSerializer): + uuid = serializers.UUIDField(source="user.uuid", read_only=True) +- total_documents = serializers.IntegerField(read_only=True) + days_since_last_upload = serializers.SerializerMethodField( + help_text="Number of days since the last time the user uploaded a document" + ) + last_login_at = serializers.DateTimeField(source="user.last_login", read_only=True) +- recent_upload_count = serializers.SerializerMethodField( ++ total_documents = serializers.IntegerField(read_only=True) ++ recent_upload_count = serializers.IntegerField( ++ read_only=True, + help_text="Number of documents uploaded within the configured recent window " + "(UPLOAD_WINDOW_DAYS, currently defaults to 90)." + ) +@@ -38,9 +39,6 @@ class UserStatsSerializer(serializers.ModelSerializer): + return None + return (timezone.now() - obj.last_upload_at).days + +- def get_recent_upload_count(self, obj): +- return getattr(obj, "recent_upload_count", None) +- + def get_individual_ai_credits(self, obj): + orgs = getattr(obj.user, "individual_orgs", []) + if not orgs: +diff --git a/documentcloud/users/stats_api/views.py b/documentcloud/users/stats_api/views.py +index de681510..5fb0de81 100644 +--- a/documentcloud/users/stats_api/views.py ++++ b/documentcloud/users/stats_api/views.py +@@ -1,6 +1,6 @@ + # Django + from django.conf import settings +-from django.db.models import Count, Prefetch, Q ++from django.db.models import Exists, OuterRef, Prefetch, Q + from django.utils import timezone + from django.utils.dateparse import parse_datetime + from rest_framework import mixins, viewsets +@@ -16,7 +16,7 @@ from django_filters import rest_framework as django_filters + + # DocumentCloud + from documentcloud.core.pagination import CursorPagination +-from documentcloud.documents.choices import Status ++from documentcloud.documents.models import Document + from documentcloud.organizations.models import Organization + from documentcloud.users.stats_api.models import UserStats + from documentcloud.users.stats_api.serializers import UserStatsSerializer +@@ -40,9 +40,7 @@ class UserStatsViewSet( + active_within_days = django_filters.NumberFilter( + method="filter_active_within_days", + label="Active in last N days (upload or login)", +- help_text=( +- "Return users who uploaded " "or logged in within the last N days." +- ), ++ help_text="Return users who uploaded or logged in within the last N days.", + ) + uploaded_within_days = django_filters.NumberFilter( + method="filter_uploaded_within_days", +@@ -100,66 +98,43 @@ class UserStatsViewSet( + filterset_class = Filter + + def get_queryset(self): +- return UserStats.objects.select_related("user") +- +- def paginate_queryset(self, queryset): +- page = super().paginate_queryset(queryset) +- annotated = self._annotate_and_prefetch( +- UserStats.objects.filter(pk__in=[u.pk for u in page]) +- ).order_by("pk") +- return list(annotated) +- +- def get_object(self): +- obj = super().get_object() +- return self._annotate_and_prefetch(UserStats.objects.filter(pk=obj.pk)).get() +- +- def _annotate_and_prefetch(self, queryset): +- cutoff = timezone.now() - timedelta(days=settings.UPLOAD_WINDOW_DAYS) +- return ( +- queryset.select_related("user") +- .prefetch_related( +- Prefetch( +- "user__organizations", +- queryset=Organization.objects.filter(individual=True), +- to_attr="individual_orgs", +- ) +- ) +- .annotate( +- total_documents=Count( +- "user__documents", +- filter=~Q(user__documents__status=Status.deleted), +- distinct=True, +- ), +- recent_upload_count=Count( +- "user__documents", +- filter=Q(user__documents__created_at__gte=cutoff) +- & ~Q(user__documents__status=Status.deleted), +- distinct=True, +- ), ++ return UserStats.objects.select_related("user").prefetch_related( ++ Prefetch( ++ "user__organizations", ++ queryset=Organization.objects.filter(individual=True), ++ to_attr="individual_orgs", + ) + ) + + @action(detail=False, methods=["get"]) + def aged_out(self, request): +- """Users with a document that crossed the window boundary since `since`, +- so their upload count has dropped and needs re-syncing.""" ++ """Users with a document that crossed the recent-upload window boundary ++ since `since`, so their recent_upload_count has dropped without any event. ++ Lets the caller (Squarelet) know which users to re-sync. ++ """ + since = request.query_params.get("since") + if not since: + return Response({"error": "since query param is required"}, status=400) + since_dt = parse_datetime(since) + if since_dt is None: + return Response({"error": "since must be an ISO 8601 datetime"}, status=400) ++ if timezone.is_naive(since_dt): ++ since_dt = timezone.make_aware(since_dt, timezone.utc) + + win = timedelta(days=settings.UPLOAD_WINDOW_DAYS) + now = timezone.now() +- qs = ( +- self.get_queryset() +- .filter( +- user__documents__created_at__gte=since_dt - win, +- user__documents__created_at__lt=now - win, +- ) +- .distinct() ++ ++ # Documents that left the recent window since `since`. Exists() short-circuits ++ # per user instead of joining + distinct over a heavy user's whole document ++ # set (which can time out). ++ aged_doc = Document.objects.filter( ++ user_id=OuterRef("user_id"), ++ created_at__gte=since_dt - win, ++ created_at__lt=now - win, + ) ++ qs = self.get_queryset().filter(Exists(aged_doc)) + + page = self.paginate_queryset(qs) +- return self.get_paginated_response(self.get_serializer(page, many=True).data) ++ return self.get_paginated_response( ++ self.get_serializer(page, many=True).data ++ ) diff --git a/config/settings/base.py b/config/settings/base.py index eb9d0226..dace4e2f 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -398,6 +398,11 @@ "task": "documentcloud.users.tasks.permission_digest", "schedule": crontab(day_of_week="mon", hour=7, minute=0), }, + "recompute_user_and_org_stats": { + "task": "documentcloud.core.tasks.recompute_user_and_org_stats", + "schedule": crontab(hour=2, minute=0), + "options": {"time_limit": 1800, "soft_time_limit": 1740}, + }, } PERMISSIONS_DIGEST_EMAILS = env.list("PERMISSIONS_DIGEST_EMAILS", default=[]) diff --git a/documentcloud/common/serverless/tests/test_error_handling.py b/documentcloud/common/serverless/tests/test_error_handling.py index db26b324..23c0a8eb 100644 --- a/documentcloud/common/serverless/tests/test_error_handling.py +++ b/documentcloud/common/serverless/tests/test_error_handling.py @@ -11,6 +11,7 @@ # Third Party import pytest +from sharedmock.mock import SharedMock # DocumentCloud from documentcloud.common import redis_fields @@ -26,7 +27,6 @@ StorageHandler, Workspace, ) -from sharedmock.mock import SharedMock # Since redis is used in the SharedMock calls, it needs to be pickle-able # in order to be sent across the process boundary. FakeRedis and Mock's both diff --git a/documentcloud/core/management/commands/recompute_user_and_org_stats.py b/documentcloud/core/management/commands/recompute_user_and_org_stats.py new file mode 100644 index 00000000..e14b0f5d --- /dev/null +++ b/documentcloud/core/management/commands/recompute_user_and_org_stats.py @@ -0,0 +1,53 @@ +# Django +from django.conf import settings +from django.core.management.base import BaseCommand +from django.db.models import Count +from django.utils import timezone + +# Standard Library +from datetime import timedelta + +# DocumentCloud +from documentcloud.documents.models import Document +from documentcloud.organizations.stats_api.models import OrganizationStats +from documentcloud.users.stats_api.models import UserStats + +BATCH = 1000 + + +class Command(BaseCommand): + """Recompute stored document counts on stats rows (run nightly).""" + + help = "Recompute total_documents and recent_upload_count on stats rows" + + def handle(self, *args, **options): + cutoff = timezone.now() - timedelta(days=settings.UPLOAD_WINDOW_DAYS) + self._recompute(UserStats, "user_id", cutoff) + self._recompute(OrganizationStats, "organization_id", cutoff) + + def _recompute(self, model, key, cutoff): + totals = dict(Document.objects.values_list(key).annotate(c=Count("pk"))) + recents = dict( + Document.objects.filter(created_at__gte=cutoff) + .values_list(key) + .annotate(c=Count("pk")) + ) + to_update = [] + updated = 0 + for row in model.objects.all().iterator(chunk_size=BATCH): + k = getattr(row, key) + row.total_documents = totals.get(k, 0) + row.recent_upload_count = recents.get(k, 0) + to_update.append(row) + if len(to_update) >= BATCH: + model.objects.bulk_update( + to_update, ["total_documents", "recent_upload_count"] + ) + updated += len(to_update) + to_update = [] + if to_update: + model.objects.bulk_update( + to_update, ["total_documents", "recent_upload_count"] + ) + updated += len(to_update) + self.stdout.write(self.style.SUCCESS(f"{model.__name__}: {updated:,} updated")) diff --git a/documentcloud/core/tasks.py b/documentcloud/core/tasks.py new file mode 100644 index 00000000..a1debcc0 --- /dev/null +++ b/documentcloud/core/tasks.py @@ -0,0 +1,9 @@ +# Django +from celery import shared_task +from django.core.management import call_command + + +@shared_task +def recompute_user_and_org_stats(): + """Nightly recompute of stored document counts on stats rows.""" + call_command("recompute__user_and_org_stats") diff --git a/documentcloud/organizations/migrations/0025_organizationstats_recent_upload_count_and_more.py b/documentcloud/organizations/migrations/0025_organizationstats_recent_upload_count_and_more.py new file mode 100644 index 00000000..6363dd04 --- /dev/null +++ b/documentcloud/organizations/migrations/0025_organizationstats_recent_upload_count_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.15 on 2026-09-10 19:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("organizations", "0024_organizationstats_last_ai_credit_at"), + ] + + operations = [ + migrations.AddField( + model_name="organizationstats", + name="recent_upload_count", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="organizationstats", + name="total_documents", + field=models.IntegerField(default=0), + ), + ] diff --git a/documentcloud/organizations/stats_api/models.py b/documentcloud/organizations/stats_api/models.py index 751b1077..93fcc280 100644 --- a/documentcloud/organizations/stats_api/models.py +++ b/documentcloud/organizations/stats_api/models.py @@ -11,6 +11,8 @@ class OrganizationStats(models.Model): ) last_upload_at = models.DateTimeField(null=True, blank=True, db_index=True) last_ai_credit_at = models.DateTimeField(null=True, blank=True, db_index=True) + total_documents = models.IntegerField(default=0) + recent_upload_count = models.IntegerField(default=0) class Meta: app_label = "organizations" diff --git a/documentcloud/organizations/stats_api/serializers.py b/documentcloud/organizations/stats_api/serializers.py index 558576d4..29dc87dd 100644 --- a/documentcloud/organizations/stats_api/serializers.py +++ b/documentcloud/organizations/stats_api/serializers.py @@ -10,9 +10,10 @@ class OrganizationStatsSerializer(serializers.ModelSerializer): uuid = serializers.UUIDField(source="organization.uuid", read_only=True) total_documents = serializers.IntegerField(read_only=True) days_since_last_upload = serializers.SerializerMethodField() - recent_upload_count = serializers.SerializerMethodField( - help_text="Documents uploaded by the org within the configured window " - "(UPLOAD_WINDOW_DAYS, defaults to 90)." + recent_upload_count = serializers.IntegerField( + read_only=True, + help_text="Number of documents uploaded within the configured recent window " + "(UPLOAD_WINDOW_DAYS, currently defaults to 90).", ) ai_credits = serializers.SerializerMethodField() @@ -34,9 +35,6 @@ def get_days_since_last_upload(self, obj): return None return (timezone.now() - obj.last_upload_at).days - def get_recent_upload_count(self, obj): - return getattr(obj, "recent_upload_count", None) - def get_ai_credits(self, obj): org = obj.organization return { diff --git a/documentcloud/organizations/stats_api/views.py b/documentcloud/organizations/stats_api/views.py index d8d6ed2d..e612d64e 100644 --- a/documentcloud/organizations/stats_api/views.py +++ b/documentcloud/organizations/stats_api/views.py @@ -1,6 +1,6 @@ # Django from django.conf import settings -from django.db.models import Count, Q +from django.db.models import Exists, OuterRef from django.utils import timezone from django.utils.dateparse import parse_datetime from rest_framework import mixins, viewsets @@ -16,7 +16,7 @@ # DocumentCloud from documentcloud.core.pagination import CursorPagination -from documentcloud.documents.choices import Status +from documentcloud.documents.models import Document from documentcloud.organizations.stats_api.models import OrganizationStats from documentcloud.organizations.stats_api.serializers import ( OrganizationStatsSerializer, @@ -58,64 +58,40 @@ class Meta: filterset_class = Filter def get_queryset(self): - return OrganizationStats.objects.select_related("organization").filter( - organization__individual=False - ) - - def _annotate_and_prefetch(self, queryset): - cutoff = timezone.now() - timedelta(days=settings.UPLOAD_WINDOW_DAYS) return ( - queryset.select_related("organization", "organization__parent") - .prefetch_related("organization__groups") - .annotate( - total_documents=Count( - "organization__documents", - filter=~Q(organization__documents__status=Status.deleted), - distinct=True, - ), - recent_upload_count=Count( - "organization__documents", - filter=Q(organization__documents__created_at__gte=cutoff) - & ~Q(organization__documents__status=Status.deleted), - distinct=True, - ), + OrganizationStats.objects.select_related( + "organization", "organization__parent" ) + .prefetch_related("organization__groups") + .filter(organization__individual=False) ) - def paginate_queryset(self, queryset): - page = super().paginate_queryset(queryset) - annotated = self._annotate_and_prefetch( - OrganizationStats.objects.filter(pk__in=[o.pk for o in page]) - ).order_by("pk") - return list(annotated) - - def get_object(self): - obj = super().get_object() - return self._annotate_and_prefetch( - OrganizationStats.objects.filter(pk=obj.pk) - ).get() - @action(detail=False, methods=["get"]) def aged_out(self, request): - """Orgs with a document that crossed the window boundary since `since`, - so their upload count has dropped and needs re-syncing.""" + """Orgs with a document that crossed the recent-upload window boundary + since `since`, so their recent_upload_count has dropped without any event. + Lets the caller (Squarelet) know which orgs to re-sync. + """ since = request.query_params.get("since") if not since: return Response({"error": "since query param is required"}, status=400) since_dt = parse_datetime(since) if since_dt is None: return Response({"error": "since must be an ISO 8601 datetime"}, status=400) + if timezone.is_naive(since_dt): + since_dt = timezone.make_aware(since_dt, timezone.utc) win = timedelta(days=settings.UPLOAD_WINDOW_DAYS) now = timezone.now() - qs = ( - self.get_queryset() - .filter( - organization__documents__created_at__gte=since_dt - win, - organization__documents__created_at__lt=now - win, - ) - .distinct() + + # Exists() short-circuits per org instead of joining + distinct over a + # heavy org's whole document set which can time out. + aged_doc = Document.objects.filter( + organization_id=OuterRef("organization_id"), + created_at__gte=since_dt - win, + created_at__lt=now - win, ) + qs = self.get_queryset().filter(Exists(aged_doc)) page = self.paginate_queryset(qs) return self.get_paginated_response(self.get_serializer(page, many=True).data) diff --git a/documentcloud/users/migrations/0015_userstats_recent_upload_count_and_more.py b/documentcloud/users/migrations/0015_userstats_recent_upload_count_and_more.py new file mode 100644 index 00000000..af95cdbe --- /dev/null +++ b/documentcloud/users/migrations/0015_userstats_recent_upload_count_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.15 on 2026-09-10 19:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0014_userstats_last_ai_credit_at"), + ] + + operations = [ + migrations.AddField( + model_name="userstats", + name="recent_upload_count", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="userstats", + name="total_documents", + field=models.IntegerField(default=0), + ), + ] diff --git a/documentcloud/users/stats_api/models.py b/documentcloud/users/stats_api/models.py index ecd384be..f4950ecd 100644 --- a/documentcloud/users/stats_api/models.py +++ b/documentcloud/users/stats_api/models.py @@ -11,6 +11,8 @@ class UserStats(models.Model): ) last_upload_at = models.DateTimeField(null=True, blank=True, db_index=True) last_ai_credit_at = models.DateTimeField(null=True, blank=True, db_index=True) + total_documents = models.IntegerField(default=0) + recent_upload_count = models.IntegerField(default=0) class Meta: app_label = "users" diff --git a/documentcloud/users/stats_api/serializers.py b/documentcloud/users/stats_api/serializers.py index a7452f93..d88fa887 100644 --- a/documentcloud/users/stats_api/serializers.py +++ b/documentcloud/users/stats_api/serializers.py @@ -8,14 +8,15 @@ class UserStatsSerializer(serializers.ModelSerializer): uuid = serializers.UUIDField(source="user.uuid", read_only=True) - total_documents = serializers.IntegerField(read_only=True) days_since_last_upload = serializers.SerializerMethodField( help_text="Number of days since the last time the user uploaded a document" ) last_login_at = serializers.DateTimeField(source="user.last_login", read_only=True) - recent_upload_count = serializers.SerializerMethodField( + total_documents = serializers.IntegerField(read_only=True) + recent_upload_count = serializers.IntegerField( + read_only=True, help_text="Number of documents uploaded within the configured recent window " - "(UPLOAD_WINDOW_DAYS, currently defaults to 90)." + "(UPLOAD_WINDOW_DAYS, currently defaults to 90).", ) individual_ai_credits = serializers.SerializerMethodField() @@ -38,9 +39,6 @@ def get_days_since_last_upload(self, obj): return None return (timezone.now() - obj.last_upload_at).days - def get_recent_upload_count(self, obj): - return getattr(obj, "recent_upload_count", None) - def get_individual_ai_credits(self, obj): orgs = getattr(obj.user, "individual_orgs", []) if not orgs: diff --git a/documentcloud/users/stats_api/views.py b/documentcloud/users/stats_api/views.py index de681510..9a585e72 100644 --- a/documentcloud/users/stats_api/views.py +++ b/documentcloud/users/stats_api/views.py @@ -1,6 +1,6 @@ # Django from django.conf import settings -from django.db.models import Count, Prefetch, Q +from django.db.models import Exists, OuterRef, Prefetch, Q from django.utils import timezone from django.utils.dateparse import parse_datetime from rest_framework import mixins, viewsets @@ -16,7 +16,7 @@ # DocumentCloud from documentcloud.core.pagination import CursorPagination -from documentcloud.documents.choices import Status +from documentcloud.documents.models import Document from documentcloud.organizations.models import Organization from documentcloud.users.stats_api.models import UserStats from documentcloud.users.stats_api.serializers import UserStatsSerializer @@ -40,9 +40,7 @@ class Filter(django_filters.FilterSet): active_within_days = django_filters.NumberFilter( method="filter_active_within_days", label="Active in last N days (upload or login)", - help_text=( - "Return users who uploaded " "or logged in within the last N days." - ), + help_text="Return users who uploaded or logged in within the last N days.", ) uploaded_within_days = django_filters.NumberFilter( method="filter_uploaded_within_days", @@ -100,66 +98,41 @@ class Meta: filterset_class = Filter def get_queryset(self): - return UserStats.objects.select_related("user") - - def paginate_queryset(self, queryset): - page = super().paginate_queryset(queryset) - annotated = self._annotate_and_prefetch( - UserStats.objects.filter(pk__in=[u.pk for u in page]) - ).order_by("pk") - return list(annotated) - - def get_object(self): - obj = super().get_object() - return self._annotate_and_prefetch(UserStats.objects.filter(pk=obj.pk)).get() - - def _annotate_and_prefetch(self, queryset): - cutoff = timezone.now() - timedelta(days=settings.UPLOAD_WINDOW_DAYS) - return ( - queryset.select_related("user") - .prefetch_related( - Prefetch( - "user__organizations", - queryset=Organization.objects.filter(individual=True), - to_attr="individual_orgs", - ) - ) - .annotate( - total_documents=Count( - "user__documents", - filter=~Q(user__documents__status=Status.deleted), - distinct=True, - ), - recent_upload_count=Count( - "user__documents", - filter=Q(user__documents__created_at__gte=cutoff) - & ~Q(user__documents__status=Status.deleted), - distinct=True, - ), + return UserStats.objects.select_related("user").prefetch_related( + Prefetch( + "user__organizations", + queryset=Organization.objects.filter(individual=True), + to_attr="individual_orgs", ) ) @action(detail=False, methods=["get"]) def aged_out(self, request): - """Users with a document that crossed the window boundary since `since`, - so their upload count has dropped and needs re-syncing.""" + """Users with a document that crossed the recent-upload window boundary + since `since`, so their recent_upload_count has dropped without any event. + Lets the caller (Squarelet) know which users to re-sync. + """ since = request.query_params.get("since") if not since: return Response({"error": "since query param is required"}, status=400) since_dt = parse_datetime(since) if since_dt is None: return Response({"error": "since must be an ISO 8601 datetime"}, status=400) + if timezone.is_naive(since_dt): + since_dt = timezone.make_aware(since_dt, timezone.utc) win = timedelta(days=settings.UPLOAD_WINDOW_DAYS) now = timezone.now() - qs = ( - self.get_queryset() - .filter( - user__documents__created_at__gte=since_dt - win, - user__documents__created_at__lt=now - win, - ) - .distinct() + + # Documents that left the recent window since `since`. Exists() short-circuits + # per user instead of joining + distinct over a heavy user's whole document + # set (which can time out). + aged_doc = Document.objects.filter( + user_id=OuterRef("user_id"), + created_at__gte=since_dt - win, + created_at__lt=now - win, ) + qs = self.get_queryset().filter(Exists(aged_doc)) page = self.paginate_queryset(qs) return self.get_paginated_response(self.get_serializer(page, many=True).data) From 5a4f57bbde4c90869c316407afe6fe27829442d6 Mon Sep 17 00:00:00 2001 From: Sanjin <102841251+duckduckgrayduck@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:11:18 -0500 Subject: [PATCH 2/3] Restore serverless test to master (unintended rebase artifact) --- changes.txt | 329 ------------------ .../serverless/tests/test_error_handling.py | 2 +- .../organizations/stats_api/tests.py | 14 +- documentcloud/users/stats_api/tests.py | 9 +- 4 files changed, 12 insertions(+), 342 deletions(-) delete mode 100644 changes.txt diff --git a/changes.txt b/changes.txt deleted file mode 100644 index 5bbe9495..00000000 --- a/changes.txt +++ /dev/null @@ -1,329 +0,0 @@ -diff --git a/config/settings/base.py b/config/settings/base.py -index a55ab8c2..49eb66ae 100644 ---- a/config/settings/base.py -+++ b/config/settings/base.py -@@ -398,6 +398,11 @@ CELERY_BEAT_SCHEDULE = { - "task": "documentcloud.users.tasks.permission_digest", - "schedule": crontab(day_of_week="mon", hour=7, minute=0), - }, -+ "recompute_user_and_org_stats": { -+ "task": "documentcloud.core.tasks.recompute_user_and_org_stats", -+ "schedule": crontab(hour=2, minute=0), -+ "options": {"time_limit": 1800, "soft_time_limit": 1740}, -+ }, - } - - PERMISSIONS_DIGEST_EMAILS = env.list("PERMISSIONS_DIGEST_EMAILS", default=[]) -diff --git a/documentcloud/organizations/stats_api/models.py b/documentcloud/organizations/stats_api/models.py -index 751b1077..93fcc280 100644 ---- a/documentcloud/organizations/stats_api/models.py -+++ b/documentcloud/organizations/stats_api/models.py -@@ -11,6 +11,8 @@ class OrganizationStats(models.Model): - ) - last_upload_at = models.DateTimeField(null=True, blank=True, db_index=True) - last_ai_credit_at = models.DateTimeField(null=True, blank=True, db_index=True) -+ total_documents = models.IntegerField(default=0) -+ recent_upload_count = models.IntegerField(default=0) - - class Meta: - app_label = "organizations" -diff --git a/documentcloud/organizations/stats_api/serializers.py b/documentcloud/organizations/stats_api/serializers.py -index 558576d4..a5bad06b 100644 ---- a/documentcloud/organizations/stats_api/serializers.py -+++ b/documentcloud/organizations/stats_api/serializers.py -@@ -10,9 +10,10 @@ class OrganizationStatsSerializer(serializers.ModelSerializer): - uuid = serializers.UUIDField(source="organization.uuid", read_only=True) - total_documents = serializers.IntegerField(read_only=True) - days_since_last_upload = serializers.SerializerMethodField() -- recent_upload_count = serializers.SerializerMethodField( -- help_text="Documents uploaded by the org within the configured window " -- "(UPLOAD_WINDOW_DAYS, defaults to 90)." -+ recent_upload_count = serializers.IntegerField( -+ read_only=True, -+ help_text="Number of documents uploaded within the configured recent window " -+ "(UPLOAD_WINDOW_DAYS, currently defaults to 90)." - ) - ai_credits = serializers.SerializerMethodField() - -@@ -34,9 +35,6 @@ class OrganizationStatsSerializer(serializers.ModelSerializer): - return None - return (timezone.now() - obj.last_upload_at).days - -- def get_recent_upload_count(self, obj): -- return getattr(obj, "recent_upload_count", None) -- - def get_ai_credits(self, obj): - org = obj.organization - return { -diff --git a/documentcloud/organizations/stats_api/views.py b/documentcloud/organizations/stats_api/views.py -index d8d6ed2d..422a3e05 100644 ---- a/documentcloud/organizations/stats_api/views.py -+++ b/documentcloud/organizations/stats_api/views.py -@@ -1,6 +1,6 @@ - # Django - from django.conf import settings --from django.db.models import Count, Q -+from django.db.models import Exists, OuterRef - from django.utils import timezone - from django.utils.dateparse import parse_datetime - from rest_framework import mixins, viewsets -@@ -16,7 +16,7 @@ from django_filters import rest_framework as django_filters - - # DocumentCloud - from documentcloud.core.pagination import CursorPagination --from documentcloud.documents.choices import Status -+from documentcloud.documents.models import Document - from documentcloud.organizations.stats_api.models import OrganizationStats - from documentcloud.organizations.stats_api.serializers import ( - OrganizationStatsSerializer, -@@ -58,64 +58,42 @@ class OrganizationStatsViewSet( - filterset_class = Filter - - def get_queryset(self): -- return OrganizationStats.objects.select_related("organization").filter( -- organization__individual=False -- ) -- -- def _annotate_and_prefetch(self, queryset): -- cutoff = timezone.now() - timedelta(days=settings.UPLOAD_WINDOW_DAYS) - return ( -- queryset.select_related("organization", "organization__parent") -- .prefetch_related("organization__groups") -- .annotate( -- total_documents=Count( -- "organization__documents", -- filter=~Q(organization__documents__status=Status.deleted), -- distinct=True, -- ), -- recent_upload_count=Count( -- "organization__documents", -- filter=Q(organization__documents__created_at__gte=cutoff) -- & ~Q(organization__documents__status=Status.deleted), -- distinct=True, -- ), -+ OrganizationStats.objects.select_related( -+ "organization", "organization__parent" - ) -+ .prefetch_related("organization__groups") -+ .filter(organization__individual=False) - ) - -- def paginate_queryset(self, queryset): -- page = super().paginate_queryset(queryset) -- annotated = self._annotate_and_prefetch( -- OrganizationStats.objects.filter(pk__in=[o.pk for o in page]) -- ).order_by("pk") -- return list(annotated) -- -- def get_object(self): -- obj = super().get_object() -- return self._annotate_and_prefetch( -- OrganizationStats.objects.filter(pk=obj.pk) -- ).get() -- - @action(detail=False, methods=["get"]) - def aged_out(self, request): -- """Orgs with a document that crossed the window boundary since `since`, -- so their upload count has dropped and needs re-syncing.""" -+ """Orgs with a document that crossed the recent-upload window boundary -+ since `since`, so their recent_upload_count has dropped without any event. -+ Lets the caller (Squarelet) know which orgs to re-sync. -+ """ - since = request.query_params.get("since") - if not since: - return Response({"error": "since query param is required"}, status=400) - since_dt = parse_datetime(since) - if since_dt is None: - return Response({"error": "since must be an ISO 8601 datetime"}, status=400) -+ if timezone.is_naive(since_dt): -+ since_dt = timezone.make_aware(since_dt, timezone.utc) - - win = timedelta(days=settings.UPLOAD_WINDOW_DAYS) - now = timezone.now() -- qs = ( -- self.get_queryset() -- .filter( -- organization__documents__created_at__gte=since_dt - win, -- organization__documents__created_at__lt=now - win, -- ) -- .distinct() -+ -+ # Exists() short-circuits per org instead of joining + distinct over a -+ # heavy org's whole document set which can time out. -+ aged_doc = Document.objects.filter( -+ organization_id=OuterRef("organization_id"), -+ created_at__gte=since_dt - win, -+ created_at__lt=now - win, - ) -+ qs = self.get_queryset().filter(Exists(aged_doc)) - - page = self.paginate_queryset(qs) -- return self.get_paginated_response(self.get_serializer(page, many=True).data) -+ return self.get_paginated_response( -+ self.get_serializer(page, many=True).data -+ ) -diff --git a/documentcloud/users/stats_api/models.py b/documentcloud/users/stats_api/models.py -index ecd384be..f4950ecd 100644 ---- a/documentcloud/users/stats_api/models.py -+++ b/documentcloud/users/stats_api/models.py -@@ -11,6 +11,8 @@ class UserStats(models.Model): - ) - last_upload_at = models.DateTimeField(null=True, blank=True, db_index=True) - last_ai_credit_at = models.DateTimeField(null=True, blank=True, db_index=True) -+ total_documents = models.IntegerField(default=0) -+ recent_upload_count = models.IntegerField(default=0) - - class Meta: - app_label = "users" -diff --git a/documentcloud/users/stats_api/serializers.py b/documentcloud/users/stats_api/serializers.py -index a7452f93..319e3c3b 100644 ---- a/documentcloud/users/stats_api/serializers.py -+++ b/documentcloud/users/stats_api/serializers.py -@@ -8,12 +8,13 @@ from documentcloud.users.stats_api.models import UserStats - - class UserStatsSerializer(serializers.ModelSerializer): - uuid = serializers.UUIDField(source="user.uuid", read_only=True) -- total_documents = serializers.IntegerField(read_only=True) - days_since_last_upload = serializers.SerializerMethodField( - help_text="Number of days since the last time the user uploaded a document" - ) - last_login_at = serializers.DateTimeField(source="user.last_login", read_only=True) -- recent_upload_count = serializers.SerializerMethodField( -+ total_documents = serializers.IntegerField(read_only=True) -+ recent_upload_count = serializers.IntegerField( -+ read_only=True, - help_text="Number of documents uploaded within the configured recent window " - "(UPLOAD_WINDOW_DAYS, currently defaults to 90)." - ) -@@ -38,9 +39,6 @@ class UserStatsSerializer(serializers.ModelSerializer): - return None - return (timezone.now() - obj.last_upload_at).days - -- def get_recent_upload_count(self, obj): -- return getattr(obj, "recent_upload_count", None) -- - def get_individual_ai_credits(self, obj): - orgs = getattr(obj.user, "individual_orgs", []) - if not orgs: -diff --git a/documentcloud/users/stats_api/views.py b/documentcloud/users/stats_api/views.py -index de681510..5fb0de81 100644 ---- a/documentcloud/users/stats_api/views.py -+++ b/documentcloud/users/stats_api/views.py -@@ -1,6 +1,6 @@ - # Django - from django.conf import settings --from django.db.models import Count, Prefetch, Q -+from django.db.models import Exists, OuterRef, Prefetch, Q - from django.utils import timezone - from django.utils.dateparse import parse_datetime - from rest_framework import mixins, viewsets -@@ -16,7 +16,7 @@ from django_filters import rest_framework as django_filters - - # DocumentCloud - from documentcloud.core.pagination import CursorPagination --from documentcloud.documents.choices import Status -+from documentcloud.documents.models import Document - from documentcloud.organizations.models import Organization - from documentcloud.users.stats_api.models import UserStats - from documentcloud.users.stats_api.serializers import UserStatsSerializer -@@ -40,9 +40,7 @@ class UserStatsViewSet( - active_within_days = django_filters.NumberFilter( - method="filter_active_within_days", - label="Active in last N days (upload or login)", -- help_text=( -- "Return users who uploaded " "or logged in within the last N days." -- ), -+ help_text="Return users who uploaded or logged in within the last N days.", - ) - uploaded_within_days = django_filters.NumberFilter( - method="filter_uploaded_within_days", -@@ -100,66 +98,43 @@ class UserStatsViewSet( - filterset_class = Filter - - def get_queryset(self): -- return UserStats.objects.select_related("user") -- -- def paginate_queryset(self, queryset): -- page = super().paginate_queryset(queryset) -- annotated = self._annotate_and_prefetch( -- UserStats.objects.filter(pk__in=[u.pk for u in page]) -- ).order_by("pk") -- return list(annotated) -- -- def get_object(self): -- obj = super().get_object() -- return self._annotate_and_prefetch(UserStats.objects.filter(pk=obj.pk)).get() -- -- def _annotate_and_prefetch(self, queryset): -- cutoff = timezone.now() - timedelta(days=settings.UPLOAD_WINDOW_DAYS) -- return ( -- queryset.select_related("user") -- .prefetch_related( -- Prefetch( -- "user__organizations", -- queryset=Organization.objects.filter(individual=True), -- to_attr="individual_orgs", -- ) -- ) -- .annotate( -- total_documents=Count( -- "user__documents", -- filter=~Q(user__documents__status=Status.deleted), -- distinct=True, -- ), -- recent_upload_count=Count( -- "user__documents", -- filter=Q(user__documents__created_at__gte=cutoff) -- & ~Q(user__documents__status=Status.deleted), -- distinct=True, -- ), -+ return UserStats.objects.select_related("user").prefetch_related( -+ Prefetch( -+ "user__organizations", -+ queryset=Organization.objects.filter(individual=True), -+ to_attr="individual_orgs", - ) - ) - - @action(detail=False, methods=["get"]) - def aged_out(self, request): -- """Users with a document that crossed the window boundary since `since`, -- so their upload count has dropped and needs re-syncing.""" -+ """Users with a document that crossed the recent-upload window boundary -+ since `since`, so their recent_upload_count has dropped without any event. -+ Lets the caller (Squarelet) know which users to re-sync. -+ """ - since = request.query_params.get("since") - if not since: - return Response({"error": "since query param is required"}, status=400) - since_dt = parse_datetime(since) - if since_dt is None: - return Response({"error": "since must be an ISO 8601 datetime"}, status=400) -+ if timezone.is_naive(since_dt): -+ since_dt = timezone.make_aware(since_dt, timezone.utc) - - win = timedelta(days=settings.UPLOAD_WINDOW_DAYS) - now = timezone.now() -- qs = ( -- self.get_queryset() -- .filter( -- user__documents__created_at__gte=since_dt - win, -- user__documents__created_at__lt=now - win, -- ) -- .distinct() -+ -+ # Documents that left the recent window since `since`. Exists() short-circuits -+ # per user instead of joining + distinct over a heavy user's whole document -+ # set (which can time out). -+ aged_doc = Document.objects.filter( -+ user_id=OuterRef("user_id"), -+ created_at__gte=since_dt - win, -+ created_at__lt=now - win, - ) -+ qs = self.get_queryset().filter(Exists(aged_doc)) - - page = self.paginate_queryset(qs) -- return self.get_paginated_response(self.get_serializer(page, many=True).data) -+ return self.get_paginated_response( -+ self.get_serializer(page, many=True).data -+ ) diff --git a/documentcloud/common/serverless/tests/test_error_handling.py b/documentcloud/common/serverless/tests/test_error_handling.py index 23c0a8eb..db26b324 100644 --- a/documentcloud/common/serverless/tests/test_error_handling.py +++ b/documentcloud/common/serverless/tests/test_error_handling.py @@ -11,7 +11,6 @@ # Third Party import pytest -from sharedmock.mock import SharedMock # DocumentCloud from documentcloud.common import redis_fields @@ -27,6 +26,7 @@ StorageHandler, Workspace, ) +from sharedmock.mock import SharedMock # Since redis is used in the SharedMock calls, it needs to be pickle-able # in order to be sent across the process boundary. FakeRedis and Mock's both diff --git a/documentcloud/organizations/stats_api/tests.py b/documentcloud/organizations/stats_api/tests.py index e5c096ad..af1253a7 100644 --- a/documentcloud/organizations/stats_api/tests.py +++ b/documentcloud/organizations/stats_api/tests.py @@ -1,5 +1,6 @@ # Django from django.conf import settings +from django.core.management import call_command from django.test import TestCase from django.utils import timezone from rest_framework import status @@ -107,7 +108,7 @@ def test_list_unauthenticated(self, client): assert response.status_code == status.HTTP_403_FORBIDDEN def test_list_excludes_individual_orgs(self, client): - """Users pull individual orgs in on the users endpoint""" + """Individual orgs are excluded from the org stats endpoint.""" client.force_authenticate(user=self._admin()) collective = OrganizationFactory.create_batch(3, individual=False) individual = OrganizationFactory(individual=True) @@ -120,11 +121,12 @@ def test_list_excludes_individual_orgs(self, client): assert str(individual.uuid) not in uuids def test_retrieve_populates_enriched_fields(self, client): - """Regression test. org detail view populates the annotated counts, which - were previously only set in paginate_queryset (list view).""" + """Detail view serves the stored document counts, populated by the + recompute command.""" client.force_authenticate(user=self._admin()) org = OrganizationFactory(individual=False) DocumentFactory.create_batch(2, organization=org) + call_command("recompute_user_and_org_stats") response = client.get(f"/stats_api/organizations/{org.uuid}/") assert response.status_code == status.HTTP_200_OK @@ -157,11 +159,8 @@ def test_document_upload_bumps_org_watermark( user, org = user_with_collective_org client.force_authenticate(user=user) - print("fixture org:", org.pk) - print("user.organization:", user.organization.pk) # same as org.pk? - response = client.post("/api/documents/", {"title": "t"}) - print("status:", response.status_code, response.content[:200]) # created? + assert response.status_code == status.HTTP_201_CREATED stats = OrganizationStats.objects.get(organization=org) assert stats.last_upload_at is not None @@ -170,7 +169,6 @@ def test_ai_credit_charge_bumps_org_watermark( self, client, user_with_collective_org ): """use_ai_credits' record_ai_credit_use call should bump the org watermark.""" - user, org = user_with_collective_org # give the org credits to spend org.monthly_ai_credits = 5 diff --git a/documentcloud/users/stats_api/tests.py b/documentcloud/users/stats_api/tests.py index 2a93fbbb..a6fced47 100644 --- a/documentcloud/users/stats_api/tests.py +++ b/documentcloud/users/stats_api/tests.py @@ -1,4 +1,5 @@ # Django +from django.core.management import call_command from django.utils import timezone from rest_framework import status @@ -40,9 +41,8 @@ def test_list_unauthenticated(self, client): assert response.status_code == status.HTTP_403_FORBIDDEN def test_retrieve_populates_enriched_fields(self, client): - """Regression: detail view must populate individual_ai_credits and the - annotated counts, not just the list view (they were only set in - paginate_queryset before).""" + """Detail view serves the stored document counts (populated by the + recompute command) and the live-computed individual_ai_credits.""" admin = self._admin() client.force_authenticate(user=admin) @@ -52,8 +52,9 @@ def test_retrieve_populates_enriched_fields(self, client): org.monthly_ai_credits = 5 org.ai_credits_per_month = 10 org.save() - # give them documents so the counts are non-zero + # give them documents, then recompute so the stored counts reflect them DocumentFactory.create_batch(2, user=target, organization=org) + call_command("recompute_user_and_org_stats") response = client.get(f"/stats_api/users/{target.uuid}/") assert response.status_code == status.HTTP_200_OK From be9b97e228fd980c9297870f34db65aca805f5de Mon Sep 17 00:00:00 2001 From: Sanjin <102841251+duckduckgrayduck@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:54:02 -0500 Subject: [PATCH 3/3] Fix double underscore --- documentcloud/core/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentcloud/core/tasks.py b/documentcloud/core/tasks.py index a1debcc0..519a233b 100644 --- a/documentcloud/core/tasks.py +++ b/documentcloud/core/tasks.py @@ -6,4 +6,4 @@ @shared_task def recompute_user_and_org_stats(): """Nightly recompute of stored document counts on stats rows.""" - call_command("recompute__user_and_org_stats") + call_command("recompute_user_and_org_stats")